@k2b/cloud 0.31.0 → 0.33.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 (173) hide show
  1. package/package.json +8 -6
  2. package/src/_internal/define-app.ts +20 -9
  3. package/src/_internal/page-responses.ts +5 -1
  4. package/src/ai/app-skills.ts +1 -7
  5. package/src/ai/audio-tool.ts +2 -1
  6. package/src/ai/browser.ts +109 -0
  7. package/src/ai/capabilities.ts +6 -2
  8. package/src/ai/chart-block.ts +431 -81
  9. package/src/ai/chat/action-sentences.ts +13 -7
  10. package/src/ai/chat/blocks.tsx +11 -7
  11. package/src/ai/chat/builtin-tools.tsx +9 -7
  12. package/src/ai/chat/chart-block.tsx +218 -44
  13. package/src/ai/chat/explorer-block.tsx +276 -0
  14. package/src/ai/chat/file-tools.tsx +40 -1
  15. package/src/ai/chat/message-actions.tsx +6 -0
  16. package/src/ai/chat/message-utils.ts +7 -0
  17. package/src/ai/chat/messages.ts +16 -0
  18. package/src/ai/chat/presentation.tsx +13 -3
  19. package/src/ai/chat/reference-text.tsx +34 -0
  20. package/src/ai/chat/tool-groups.ts +1 -0
  21. package/src/ai/chat/tool-names.ts +14 -0
  22. package/src/ai/chat/turn-view.tsx +17 -7
  23. package/src/ai/chat/view-block.browser-harness.tsx +35 -0
  24. package/src/ai/chat/view-block.tsx +417 -0
  25. package/src/ai/chat/visual-tools.tsx +32 -53
  26. package/src/ai/client-tool-results.ts +9 -0
  27. package/src/ai/code-source-contracts.ts +17 -2
  28. package/src/ai/code-source-tools.ts +1 -0
  29. package/src/ai/default-tool-contracts.ts +334 -91
  30. package/src/ai/default-tools.ts +89 -13
  31. package/src/ai/executor.ts +173 -31
  32. package/src/ai/file-error.ts +2 -0
  33. package/src/ai/file-tools.ts +27 -7
  34. package/src/ai/files-store.ts +60 -0
  35. package/src/ai/html-pdf-tool.ts +3 -2
  36. package/src/ai/http.ts +37 -12
  37. package/src/ai/index.ts +90 -0
  38. package/src/ai/local-tool-contracts.ts +244 -0
  39. package/src/ai/malformed-tool-call-repair.ts +144 -0
  40. package/src/ai/markdown-pdf-tool.ts +2 -1
  41. package/src/ai/memories.ts +48 -1
  42. package/src/ai/memory-learning.ts +17 -6
  43. package/src/ai/memory-tool.ts +1 -0
  44. package/src/ai/migrate.ts +15 -0
  45. package/src/ai/openai-api-messages.ts +53 -0
  46. package/src/ai/openai-api-slots.ts +40 -0
  47. package/src/ai/openai-api.ts +383 -0
  48. package/src/ai/protocol.ts +6 -2
  49. package/src/ai/provider.ts +17 -4
  50. package/src/ai/request-context.ts +33 -0
  51. package/src/ai/routes.ts +96 -57
  52. package/src/ai/runtime.ts +48 -12
  53. package/src/ai/settings.ts +5 -0
  54. package/src/ai/skill-catalog.ts +20 -5
  55. package/src/ai/skill-format.ts +17 -7
  56. package/src/ai/skill-tool.ts +15 -5
  57. package/src/ai/skills-routes.ts +20 -6
  58. package/src/ai/skills.ts +26 -7
  59. package/src/ai/store.ts +25 -0
  60. package/src/ai/system-prompt.ts +69 -10
  61. package/src/ai/timeline.ts +3 -1
  62. package/src/ai/tools.ts +3 -0
  63. package/src/ai/types.ts +9 -0
  64. package/src/ai/view-block.ts +941 -0
  65. package/src/ai/vision-tool.ts +2 -1
  66. package/src/api/admin-identity.ts +6 -1
  67. package/src/api/app-approval-actions.ts +36 -0
  68. package/src/api/app-approval.ts +10 -2
  69. package/src/api/approvals-loader.ts +15 -0
  70. package/src/api/approvals.ts +120 -0
  71. package/src/api/index.ts +3 -0
  72. package/src/api/me.ts +12 -3
  73. package/src/approvals/Approvals.browser-harness.tsx +16 -0
  74. package/src/approvals/Approvals.tsx +621 -0
  75. package/src/approvals/ApprovalsNavStatus.tsx +20 -0
  76. package/src/approvals/messages.ts +95 -0
  77. package/src/approvals/ui.ts +8 -0
  78. package/src/browser/app-approval.ts +32 -0
  79. package/src/browser/choose-files.tsx +26 -13
  80. package/src/browser/commands.ts +13 -4
  81. package/src/browser/file-provider-error.ts +11 -0
  82. package/src/browser/file-providers.ts +3 -12
  83. package/src/browser/files.tsx +1 -2
  84. package/src/browser/live-connection.ts +2 -3
  85. package/src/browser/navigation-search.ts +4 -1
  86. package/src/browser/provider-list.ts +2 -1
  87. package/src/browser/save-files.tsx +13 -2
  88. package/src/cli/account.ts +21 -2
  89. package/src/cli/commands.ts +4 -2
  90. package/src/contracts/app-approval.ts +120 -0
  91. package/src/contracts/approval-paths.ts +4 -0
  92. package/src/contracts/approvals.ts +81 -0
  93. package/src/contracts/command-path.ts +17 -0
  94. package/src/contracts/commands.ts +5 -15
  95. package/src/contracts/shared.ts +5 -0
  96. package/src/events/live-protocol.ts +2 -1
  97. package/src/events/live-server-message.ts +40 -0
  98. package/src/events/testing.ts +88 -0
  99. package/src/server/index.ts +2 -0
  100. package/src/server/middleware/auth.ts +11 -1
  101. package/src/server/request-timeout.ts +22 -0
  102. package/src/server/services/access.ts +4 -3
  103. package/src/services/app-approval.ts +103 -15
  104. package/src/services/approvals/cursor.ts +15 -0
  105. package/src/services/approvals/device.ts +112 -0
  106. package/src/services/approvals/index.ts +27 -11
  107. package/src/services/approvals/list.ts +120 -0
  108. package/src/services/approvals/migrate.ts +9 -1
  109. package/src/services/identity/models-only-auth.ts +25 -0
  110. package/src/services/notifications/email-frame.ts +16 -3
  111. package/src/services/outbox.ts +13 -6
  112. package/src/services/service-account-credentials.ts +22 -10
  113. package/src/services/settings/core-settings.ts +27 -0
  114. package/src/shared/ai-platform-prompt.ts +29 -3
  115. package/src/shared/email-html.ts +4 -0
  116. package/src/shared/index.ts +6 -1
  117. package/src/shared/markdown/extensions/code.ts +14 -0
  118. package/src/shared/markdown/index.ts +4 -4
  119. package/src/shared/theme.ts +41 -20
  120. package/src/ssr/AnnouncementBanners.tsx +30 -0
  121. package/src/ssr/AppBadge.tsx +4 -4
  122. package/src/ssr/AppLaunchpadPanel.tsx +6 -6
  123. package/src/ssr/CloudShell.island.tsx +91 -0
  124. package/src/ssr/GlobalSearchTrigger.tsx +51 -0
  125. package/src/ssr/Layout.tsx +56 -85
  126. package/src/ssr/LayoutHeader.tsx +31 -77
  127. package/src/ssr/LayoutHelp.tsx +2 -57
  128. package/src/ssr/LayoutHelpTrigger.tsx +51 -0
  129. package/src/ssr/{LayoutPreferences.island.tsx → LayoutPreferences.tsx} +2 -2
  130. package/src/ssr/LayoutRail.tsx +15 -69
  131. package/src/ssr/MinimalLayout.tsx +2 -3
  132. package/src/ssr/MinimalLayoutPreferences.island.tsx +12 -15
  133. package/src/ssr/MobileNavigation.tsx +13 -8
  134. package/src/ssr/MobileProfileActions.tsx +10 -7
  135. package/src/ssr/PageTrailRow.tsx +14 -0
  136. package/src/ssr/{ProfilePreferences.island.tsx → ProfilePreferences.tsx} +12 -7
  137. package/src/ssr/PwaLayout.tsx +0 -2
  138. package/src/ssr/PwaRuntime.island.tsx +1 -1
  139. package/src/ssr/{RailApps.island.tsx → RailApps.tsx} +2 -2
  140. package/src/ssr/ShellRuntime.island.tsx +170 -0
  141. package/src/ssr/TimezoneCookie.island.tsx +3 -19
  142. package/src/ssr/WorkspaceNavigationProvider.tsx +20 -1
  143. package/src/ssr/announcements-dialog.tsx +66 -0
  144. package/src/ssr/app-badges.ts +50 -6
  145. package/src/ssr/{AppLaunchpad.island.tsx → app-launchpad.tsx} +42 -62
  146. package/src/ssr/busy-action.ts +19 -0
  147. package/src/ssr/islands/index.ts +9 -6
  148. package/src/ssr/launchpad-surface.tsx +15 -0
  149. package/src/ssr/layout-help-registry.tsx +60 -0
  150. package/src/ssr/mobile-navigation.browser-harness.tsx +20 -0
  151. package/src/ssr/page-trail.browser-harness.tsx +52 -3
  152. package/src/ssr/preference-controller.ts +30 -21
  153. package/src/ssr/profile-actions.ts +26 -15
  154. package/src/ssr/profile-preferences-messages.ts +8 -4
  155. package/src/ssr/rail-context.ts +8 -12
  156. package/src/ssr/rail-navigation.ts +5 -3
  157. package/src/ssr/runtime.ts +6 -1
  158. package/src/ssr/shell-data.tsx +48 -0
  159. package/src/ssr/timezone-cookie.ts +22 -0
  160. package/src/ssr/workspace-navigation.ts +25 -2
  161. package/src/styles/approvals.css +390 -0
  162. package/src/styles/global.css +22 -7
  163. package/src/styles/utilities-navigation.css +13 -0
  164. package/src/workflows/language/compiler.ts +1 -1
  165. package/src/ssr/AppWorkspaceController.island.tsx +0 -21
  166. package/src/ssr/BrowserPushRegistration.island.tsx +0 -12
  167. package/src/ssr/GlobalAnnouncements.island.tsx +0 -117
  168. package/src/ssr/GlobalSearchTrigger.island.tsx +0 -75
  169. package/src/ssr/LayoutHelpTrigger.island.tsx +0 -61
  170. package/src/ssr/PageTrailRow.island.tsx +0 -20
  171. package/src/ssr/PageTrailRuntime.island.tsx +0 -26
  172. package/src/ssr/RegisteredHelpDocuments.island.tsx +0 -3
  173. /package/src/ssr/{LayoutBreadcrumbs.island.tsx → LayoutBreadcrumbs.tsx} +0 -0
@@ -1,11 +1,12 @@
1
- import { type AppLaunchpadApp, type AppLaunchpadContext, type AppLaunchpadLegalLink, AppLaunchpadPanel } from "./AppLaunchpadPanel";
2
- import { openCloudMobileMenu } from "./MobileNavigation";
3
-
4
- export type { AppLaunchpadApp, AppLaunchpadLegalLink } from "./AppLaunchpadPanel";
5
-
6
- import { dialogCore, IconButton, Tooltip, useLocale } from "@k2b/ui";
1
+ import { IconButton, Tooltip, useLocale } from "@k2b/ui";
7
2
  import { createEffect } from "solid-js";
3
+ import { importOnDemand } from "../browser/reload";
4
+ import type { AppLaunchpadApp, AppLaunchpadContext, AppLaunchpadLegalLink } from "./AppLaunchpadPanel";
5
+ import { createBusyAction } from "./busy-action";
8
6
  import { platformMessages } from "./platform-messages";
7
+ import { readShellData } from "./shell-data";
8
+
9
+ export type { AppLaunchpadApp, AppLaunchpadLegalLink } from "./AppLaunchpadPanel";
9
10
 
10
11
  declare global {
11
12
  interface Window {
@@ -17,29 +18,15 @@ declare global {
17
18
  }
18
19
 
19
20
  const readEmbeddedContext = (): AppLaunchpadContext | undefined => {
20
- if (typeof document === "undefined") return undefined;
21
- const element = document.getElementById("cloud-app-launchpad-data");
22
- const text = element?.textContent;
23
- if (!text) return undefined;
24
-
25
- try {
26
- const parsed = JSON.parse(text) as Partial<AppLaunchpadContext>;
27
- if (!Array.isArray(parsed.apps)) return undefined;
28
- return {
29
- apps: parsed.apps,
30
- profile:
31
- parsed.profile?.name && (parsed.profile.theme === "dark" || parsed.profile.theme === "light")
32
- ? {
33
- name: parsed.profile.name,
34
- theme: parsed.profile.theme,
35
- ...(parsed.profile.appHref === "/me/app" ? { appHref: parsed.profile.appHref } : {}),
36
- }
37
- : undefined,
38
- legalLinks: Array.isArray(parsed.legalLinks) ? parsed.legalLinks : [],
39
- };
40
- } catch {
41
- return undefined;
42
- }
21
+ const data = readShellData();
22
+ if (!data) return undefined;
23
+ return {
24
+ apps: data.apps,
25
+ profile: data.profile
26
+ ? { name: data.profile.name, theme: data.theme, ...(data.profile.appHref === "/me/app" ? { appHref: data.profile.appHref } : {}) }
27
+ : undefined,
28
+ legalLinks: data.legalLinks,
29
+ };
43
30
  };
44
31
 
45
32
  export function setAppLaunchpadContext(
@@ -55,20 +42,20 @@ export function setAppLaunchpadContext(
55
42
  };
56
43
  }
57
44
 
45
+ /** Every caller of the public opener shares one load, so a double click opens one dialog. */
46
+ let launchpadPending: Promise<void> | undefined;
47
+
58
48
  export function openAppLaunchpad(apps?: AppLaunchpadApp[], legalLinks?: AppLaunchpadLegalLink[], profile?: AppLaunchpadContext["profile"]) {
59
- if (typeof window === "undefined") return;
49
+ if (typeof window === "undefined" || launchpadPending) return;
60
50
  const context = apps ? { apps, legalLinks: legalLinks ?? [], profile } : (window.__cloudAppLaunchpad ?? readEmbeddedContext());
61
51
  if (!context) return;
62
52
  window.__cloudAppLaunchpad = context;
63
- if (window.matchMedia("(max-width: 767px)").matches) {
64
- void openCloudMobileMenu(context);
65
- return;
66
- }
67
- void dialogCore.open<void>((close) => <AppLaunchpadPanel apps={context.apps} legalLinks={context.legalLinks} close={close} />, {
68
- panelClassName: "k2b-dialog k2b-dialog--large is-bare",
69
- contentClassName: "k2b-dialog__viewport is-bare",
70
- initialFocus: (dialog) => dialog.querySelector<HTMLAnchorElement>("a[href]"),
71
- });
53
+ launchpadPending = importOnDemand(() => import("./launchpad-surface"))
54
+ .then((loaded) => loaded?.openLaunchpadSurface(context))
55
+ .finally(() => {
56
+ launchpadPending = undefined;
57
+ });
58
+ return launchpadPending;
72
59
  }
73
60
 
74
61
  export function AppLaunchpadProvider(props: AppLaunchpadContext) {
@@ -82,7 +69,16 @@ export function AppLaunchpadProvider(props: AppLaunchpadContext) {
82
69
  export function AppLaunchpadButton(props: AppLaunchpadContext & { variant: "rail" | "header" | "menu"; label?: string }) {
83
70
  const locale = useLocale();
84
71
  const t = () => platformMessages.resolve([locale()]).t;
85
- const open = () => openAppLaunchpad(props.apps, props.legalLinks, props.profile);
72
+ const action = createBusyAction();
73
+ const open = () =>
74
+ action.run(
75
+ () => importOnDemand(() => import("./launchpad-surface")),
76
+ (loaded) => {
77
+ const context = { apps: props.apps, legalLinks: props.legalLinks, profile: props.profile };
78
+ window.__cloudAppLaunchpad = context;
79
+ loaded.openLaunchpadSurface(context);
80
+ },
81
+ );
86
82
 
87
83
  if (props.variant === "rail") {
88
84
  return (
@@ -95,16 +91,17 @@ export function AppLaunchpadButton(props: AppLaunchpadContext & { variant: "rail
95
91
  delay={0}
96
92
  aria-label={props.label ?? t().openApps}
97
93
  onClick={open}
94
+ aria-busy={action.busy() ? "true" : undefined}
98
95
  >
99
- <i class="ti ti-grid-dots text-base" aria-hidden="true" />
96
+ <i class={`${action.busy() ? "ti ti-loader-2 animate-spin" : "ti ti-grid-dots"} text-base`} aria-hidden="true" />
100
97
  </Tooltip.Trigger>
101
98
  );
102
99
  }
103
100
 
104
101
  if (props.variant === "header") {
105
102
  return (
106
- <IconButton label={props.label ?? t().openApps} onClick={open}>
107
- <i class="ti ti-grid-dots text-lg" />
103
+ <IconButton label={props.label ?? t().openApps} onClick={open} aria-busy={action.busy() ? "true" : undefined}>
104
+ <i class={`${action.busy() ? "ti ti-loader-2 animate-spin" : "ti ti-grid-dots"} text-lg`} />
108
105
  </IconButton>
109
106
  );
110
107
  }
@@ -114,27 +111,10 @@ export function AppLaunchpadButton(props: AppLaunchpadContext & { variant: "rail
114
111
  type="button"
115
112
  class="flex w-full items-center gap-3 px-4 py-2 text-sm transition-colors hover:bg-white/30 dark:hover:bg-white/10 text-zinc-700 dark:text-zinc-300"
116
113
  onClick={open}
114
+ aria-busy={action.busy() ? "true" : undefined}
117
115
  >
118
- <i class="ti ti-grid-dots" />
116
+ <i class={action.busy() ? "ti ti-loader-2 animate-spin" : "ti ti-grid-dots"} />
119
117
  <span>{props.label ?? t().apps}</span>
120
118
  </button>
121
119
  );
122
120
  }
123
-
124
- export function AppLaunchpad(props: AppLaunchpadContext & { variant?: "provider" | "rail" | "header" | "menu"; label?: string }) {
125
- if (!props.variant || props.variant === "provider") {
126
- return <AppLaunchpadProvider apps={props.apps} legalLinks={props.legalLinks} profile={props.profile} />;
127
- }
128
-
129
- return (
130
- <AppLaunchpadButton
131
- apps={props.apps}
132
- legalLinks={props.legalLinks}
133
- profile={props.profile}
134
- variant={props.variant}
135
- label={props.label}
136
- />
137
- );
138
- }
139
-
140
- export default AppLaunchpad;
@@ -0,0 +1,19 @@
1
+ import { createSignal } from "solid-js";
2
+
3
+ /** Keep the trigger focused and ignore repeated clicks while its code loads. */
4
+ export function createBusyAction() {
5
+ const [busy, setBusy] = createSignal(false);
6
+ return {
7
+ busy,
8
+ async run<T>(load: () => Promise<T | undefined>, open: (loaded: T) => void) {
9
+ if (busy()) return;
10
+ setBusy(true);
11
+ try {
12
+ const loaded = await load();
13
+ if (loaded !== undefined) open(loaded);
14
+ } finally {
15
+ setBusy(false);
16
+ }
17
+ },
18
+ };
19
+ }
@@ -3,13 +3,16 @@ export { default as WorkspaceNavigationProvider } from "../WorkspaceNavigationPr
3
3
  export { provideWorkspaceNavigation } from "../workspace-navigation";
4
4
  export { default as SearchBar } from "./SearchBar";
5
5
 
6
- import { AppLaunchpadButton, AppLaunchpadProvider } from "../AppLaunchpad.island";
7
- import { LayoutHelpDocuments, LayoutHelpPage } from "../LayoutHelp";
6
+ import { AppLaunchpadButton, AppLaunchpadProvider } from "../app-launchpad";
7
+ import { LayoutHelpPage } from "../LayoutHelp";
8
+ import { LayoutHelpDocuments } from "../layout-help-registry";
8
9
 
9
- export type { AppLaunchpadApp, AppLaunchpadLegalLink } from "../AppLaunchpad.island";
10
- export { AppLaunchpadButton, AppLaunchpadProvider, openAppLaunchpad, setAppLaunchpadContext } from "../AppLaunchpad.island";
11
- export type { LayoutHelpDocumentsProps, LayoutHelpPageProps } from "../LayoutHelp";
12
- export { LayoutHelpDocuments, LayoutHelpPage, openLayoutHelpDialog } from "../LayoutHelp";
10
+ export type { AppLaunchpadApp, AppLaunchpadLegalLink } from "../app-launchpad";
11
+ export { AppLaunchpadButton, AppLaunchpadProvider, openAppLaunchpad, setAppLaunchpadContext } from "../app-launchpad";
12
+ export type { LayoutHelpPageProps } from "../LayoutHelp";
13
+ export { LayoutHelpPage, openLayoutHelpDialog } from "../LayoutHelp";
14
+ export type { LayoutHelpDocumentsProps } from "../layout-help-registry";
15
+ export { LayoutHelpDocuments } from "../layout-help-registry";
13
16
 
14
17
  export const Layout = {
15
18
  HelpDocuments: LayoutHelpDocuments,
@@ -0,0 +1,15 @@
1
+ import { dialogCore } from "@k2b/ui";
2
+ import { type AppLaunchpadContext, AppLaunchpadPanel } from "./AppLaunchpadPanel";
3
+ import { openCloudMobileMenu } from "./MobileNavigation";
4
+
5
+ export function openLaunchpadSurface(context: AppLaunchpadContext) {
6
+ if (window.matchMedia("(max-width: 767px)").matches) {
7
+ void openCloudMobileMenu(context);
8
+ return;
9
+ }
10
+ void dialogCore.open<void>((close) => <AppLaunchpadPanel apps={context.apps} legalLinks={context.legalLinks} close={close} />, {
11
+ panelClassName: "k2b-dialog k2b-dialog--large is-bare",
12
+ contentClassName: "k2b-dialog__viewport is-bare",
13
+ initialFocus: (dialog) => dialog.querySelector<HTMLAnchorElement>("a[href]"),
14
+ });
15
+ }
@@ -0,0 +1,60 @@
1
+ import type { HelpDocumentManifest } from "@k2b/cloud/shared";
2
+ import { type JSX, onCleanup, onMount } from "solid-js";
3
+
4
+ type HelpTopicBase = {
5
+ id: string;
6
+ title: string;
7
+ icon?: string;
8
+ description?: string;
9
+ order?: number;
10
+ };
11
+
12
+ export type LayoutHelpDocumentsProps = {
13
+ documents: readonly HelpDocumentManifest[];
14
+ /** Canonical standalone Help route owned by the current app. */
15
+ pageBase: string;
16
+ };
17
+ export type HelpTopic = (HelpTopicBase & { kind: "content"; children: JSX.Element }) | (HelpDocumentManifest & { kind: "document" });
18
+
19
+ export const HELP_TOPICS_EVENT = "cloud:layout-help-topics";
20
+
21
+ declare global {
22
+ interface Window {
23
+ __cloudLayoutHelpTopics?: Map<string, HelpTopic>;
24
+ __cloudLayoutHelpPageBase?: string;
25
+ }
26
+ }
27
+
28
+ export const registry = () => {
29
+ if (typeof window === "undefined") return null;
30
+ window.__cloudLayoutHelpTopics ??= new Map<string, HelpTopic>();
31
+ return window.__cloudLayoutHelpTopics;
32
+ };
33
+
34
+ const emitTopicsChanged = () => window.dispatchEvent(new Event(HELP_TOPICS_EVENT));
35
+ const registerTopic = (topic: HelpTopic) => {
36
+ const topics = registry();
37
+ if (!topics) return () => {};
38
+ topics.set(topic.id, topic);
39
+ emitTopicsChanged();
40
+ return () => {
41
+ if (topics.get(topic.id) !== topic) return;
42
+ topics.delete(topic.id);
43
+ emitTopicsChanged();
44
+ };
45
+ };
46
+
47
+ /** Register an app's server-prepared Markdown manifest with the shared help UI. */
48
+ export function LayoutHelpDocuments(props: LayoutHelpDocumentsProps) {
49
+ onMount(() => {
50
+ // Cleanup runs while this unmounts, when a parent `<Show>` may already have dropped the props' source.
51
+ const pageBase = props.pageBase;
52
+ window.__cloudLayoutHelpPageBase = pageBase;
53
+ const disposers = props.documents.map((document) => registerTopic({ ...document, kind: "document" }));
54
+ onCleanup(() => {
55
+ disposers.forEach((dispose) => dispose());
56
+ if (window.__cloudLayoutHelpPageBase === pageBase) delete window.__cloudLayoutHelpPageBase;
57
+ });
58
+ });
59
+ return null;
60
+ }
@@ -0,0 +1,20 @@
1
+ import { createNavigation } from "@k2b/ui";
2
+ import { render } from "solid-js/web";
3
+ import { openCloudMobileMenu } from "./MobileNavigation";
4
+ import { provideWorkspaceNavigation } from "./workspace-navigation";
5
+
6
+ const owner = document.getElementById("workspace")!;
7
+ render(() => {
8
+ const navigation = createNavigation({
9
+ items: () => [{ id: "notes", label: "Notes", href: "#notes", active: true }],
10
+ switcher: () => ({
11
+ currentId: "work",
12
+ label: "Work",
13
+ icon: "ti-notebook",
14
+ switchLabel: "Switch notebook",
15
+ options: [{ id: "work", label: "Work", href: "#work" }],
16
+ }),
17
+ });
18
+ provideWorkspaceNavigation(navigation, { label: () => "Workspace", owner: () => owner });
19
+ return <button onClick={() => void openCloudMobileMenu({ apps: [], legalLinks: [] })}>Open menu</button>;
20
+ }, owner);
@@ -66,11 +66,11 @@ const Page = (props: { c: unknown; title: Title; children: JSX.Element }) => (
66
66
  {props.children}
67
67
  </Layout>
68
68
  );
69
- const AccountsWorkspace = (props: { children: JSX.Element }) => (
69
+ const AccountsWorkspace = (props: { children: JSX.Element; active?: boolean }) => (
70
70
  <AppWorkspace mobileSurface="flush" class="min-h-0 flex-1">
71
71
  <AppWorkspace.Sidebar id="accounts-navigation" label="Accounts">
72
72
  <AppWorkspace.SidebarDesktop>
73
- <AppWorkspace.SidebarItem href="/app/accounts/users" icon="ti ti-user" active>
73
+ <AppWorkspace.SidebarItem href="/app/accounts/users" icon="ti ti-user" active={props.active ?? true}>
74
74
  Users
75
75
  </AppWorkspace.SidebarItem>
76
76
  </AppWorkspace.SidebarDesktop>
@@ -134,7 +134,8 @@ export const server = new Hono()
134
134
  const uid = c.req.param("uid") ?? "";
135
135
  return () => (
136
136
  <Page c={c} title={[start, accountsRoot, { title: "Users", href: "/app/accounts/users" }, { title: uid }]}>
137
- <AccountsWorkspace>
137
+ <AccountsWorkspace active={false}>
138
+ <h1 class="p-3">User Seven</h1>
138
139
  <p class="p-3">Profile of {uid}</p>
139
140
  <input aria-label="Note" class="m-3" />
140
141
  </AccountsWorkspace>
@@ -142,6 +143,54 @@ export const server = new Hono()
142
143
  );
143
144
  }),
144
145
  )
146
+ .get(
147
+ "/app/accounts/teams",
148
+ ...probe.ssr((c) => () => (
149
+ <Page c={c} title={[start, accountsRoot, { title: "Directory", href: "/app/accounts/directory" }, { title: "Teams" }]}>
150
+ <AppWorkspace mobileSurface="flush" class="min-h-0 flex-1">
151
+ <AppWorkspace.Sidebar label="Directory">
152
+ <AppWorkspace.SidebarDesktop>
153
+ <AppWorkspace.SidebarItem href="/app/accounts/teams" active>
154
+ Teams
155
+ </AppWorkspace.SidebarItem>
156
+ </AppWorkspace.SidebarDesktop>
157
+ </AppWorkspace.Sidebar>
158
+ <AppWorkspace.Content>
159
+ <AppWorkspace.Main>
160
+ <input aria-label="Search teams" placeholder="Search Teams…" />
161
+ </AppWorkspace.Main>
162
+ </AppWorkspace.Content>
163
+ </AppWorkspace>
164
+ </Page>
165
+ )),
166
+ )
167
+ .get(
168
+ "/app/accounts/teams/engineering",
169
+ ...probe.ssr((c) => () => (
170
+ <Page c={c} title={[start, accountsRoot, { title: "Teams", href: "/app/accounts/teams" }, { title: "Engineering" }]}>
171
+ <AppWorkspace mobileSurface="flush" class="min-h-0 flex-1">
172
+ <AppWorkspace.Sidebar label="Directory">
173
+ <AppWorkspace.SidebarDesktop>
174
+ <AppWorkspace.SidebarItem href="/app/accounts/teams" active="ancestor">
175
+ Teams
176
+ </AppWorkspace.SidebarItem>
177
+ </AppWorkspace.SidebarDesktop>
178
+ </AppWorkspace.Sidebar>
179
+ <AppWorkspace.Content>
180
+ <AppWorkspace.Main>Team members</AppWorkspace.Main>
181
+ </AppWorkspace.Content>
182
+ </AppWorkspace>
183
+ </Page>
184
+ )),
185
+ )
186
+ .get(
187
+ "/app/accounts/directory",
188
+ ...probe.ssr((c) => () => (
189
+ <Page c={c} title={[{ title: "Directory" }]}>
190
+ <h1>Directory</h1>
191
+ </Page>
192
+ )),
193
+ )
145
194
  .get(
146
195
  "/app/files",
147
196
  ...probe.ssr((c) => {
@@ -1,44 +1,53 @@
1
- import type { DropdownItem } from "@k2b/ui";
1
+ import type { DropdownAction, DropdownItem, DropdownSection } from "@k2b/ui";
2
2
  import { createMemo, createSignal, onCleanup, onMount } from "solid-js";
3
3
  import { profilePreferenceLocale, setLocalePreference } from "../shared/locale-preference";
4
- import { type CloudTheme, getCurrentThemePreference, setThemePreference } from "../shared/theme";
4
+ import { type CloudThemePreference, getCurrentThemePreference, setThemePreference } from "../shared/theme";
5
5
  import { profilePreferencesMessages } from "./profile-preferences-messages";
6
6
 
7
7
  const THEME_PREFERENCE_EVENT = "cloud:theme-preference";
8
+ const THEME_PREFERENCES = ["light", "dark", "system"] as const satisfies readonly CloudThemePreference[];
9
+ const THEME_ICONS: Record<CloudThemePreference, string> = { light: "ti ti-sun-high", dark: "ti ti-moon", system: "ti ti-sun-moon" };
8
10
 
9
- export const createPreferenceController = (initialTheme: CloudTheme, locale: () => string) => {
10
- const [theme, setTheme] = createSignal(initialTheme);
11
+ export const createPreferenceController = (initialTheme: CloudThemePreference, locale: () => string) => {
12
+ const [theme, updateTheme] = createSignal(initialTheme);
11
13
  const messages = () => profilePreferencesMessages.resolve([locale()]).t;
12
14
  const language = () => profilePreferenceLocale(locale());
13
15
  const nextLanguage = () => (language() === "de" ? "en" : "de");
14
- const themeLabel = () => (theme() === "light" ? messages().switchToDark : messages().switchToLight);
15
16
  const languageLabel = () => (nextLanguage() === "de" ? messages().switchToGerman : messages().switchToEnglish);
16
17
 
17
- const toggleTheme = () => {
18
- setTheme(setThemePreference(theme() === "dark" ? "light" : "dark"));
18
+ const setTheme = (mode: CloudThemePreference) => {
19
+ updateTheme(setThemePreference(mode));
19
20
  window.dispatchEvent(new Event(THEME_PREFERENCE_EVENT));
20
21
  };
21
22
  const toggleLanguage = () => setLocalePreference(nextLanguage());
22
23
 
23
24
  onMount(() => {
24
- const syncTheme = () => setTheme(getCurrentThemePreference());
25
+ const syncTheme = () => updateTheme(getCurrentThemePreference());
25
26
  syncTheme();
26
27
  window.addEventListener(THEME_PREFERENCE_EVENT, syncTheme);
27
28
  onCleanup(() => window.removeEventListener(THEME_PREFERENCE_EVENT, syncTheme));
28
29
  });
29
30
 
30
- const items = createMemo<DropdownItem[]>(() => [
31
- {
32
- action: toggleTheme,
33
- icon: theme() === "light" ? "ti ti-moon" : "ti ti-sun-high",
34
- label: themeLabel(),
35
- },
36
- {
37
- action: toggleLanguage,
38
- icon: "ti ti-language",
39
- label: languageLabel(),
40
- },
41
- ]);
31
+ const themeOption = (value: CloudThemePreference) => ({
32
+ value,
33
+ icon: THEME_ICONS[value],
34
+ label: { light: messages().themeLight, dark: messages().themeDark, system: messages().themeSystem }[value],
35
+ });
36
+ const themeOptions = () => THEME_PREFERENCES.map(themeOption);
37
+ const appearance = (): DropdownSection => ({
38
+ sectionLabel: messages().appearance,
39
+ layout: "row",
40
+ items: themeOptions().map(({ value, label, icon }) => ({
41
+ label,
42
+ icon,
43
+ choice: "radio",
44
+ checked: () => theme() === value,
45
+ closeOnSelect: false,
46
+ action: () => setTheme(value),
47
+ })),
48
+ });
49
+ const languageItem = (): DropdownAction => ({ action: toggleLanguage, icon: "ti ti-language", label: languageLabel() });
50
+ const items = createMemo<DropdownItem[]>(() => [appearance(), { items: [languageItem()] }]);
42
51
 
43
- return { items, language, languageLabel, messages, theme, themeLabel, toggleLanguage, toggleTheme };
52
+ return { items, appearance, languageItem, themeOption, themeOptions, language, languageLabel, messages, theme, setTheme, toggleLanguage };
44
53
  };
@@ -1,18 +1,24 @@
1
1
  import { type DropdownItem, toast, useLocale } from "@k2b/ui";
2
2
  import { createMemo, createSignal } from "solid-js";
3
- import { browserNotificationClient } from "../browser/notifications";
4
- import { apiClient } from "../clients/core";
5
- import type { CloudTheme } from "../shared/theme";
3
+ import { importOnDemand } from "../browser/reload";
4
+ import type { CloudThemePreference } from "../shared/theme";
6
5
  import { createPreferenceController } from "./preference-controller";
7
6
 
8
7
  /** `appHref` is set while the installation offers the mobile app; the item then leads to `/me/app`. */
9
- export function createProfileActions(initialTheme: CloudTheme, appHref?: string) {
8
+ export function createProfileActions(initialTheme: CloudThemePreference, appHref?: string) {
10
9
  const locale = useLocale();
11
10
  const preferences = createPreferenceController(initialTheme, locale);
12
11
  const [signingOut, setSigningOut] = createSignal(false);
13
12
  const signOut = async () => {
14
13
  if (signingOut()) return;
15
14
  setSigningOut(true);
15
+ // The API client and the push client load with the first sign-out, not with every page.
16
+ const loaded = await importOnDemand(() => Promise.all([import("../browser/notifications"), import("../clients/core")]));
17
+ if (!loaded) {
18
+ setSigningOut(false);
19
+ return;
20
+ }
21
+ const [{ browserNotificationClient }, { apiClient }] = loaded;
16
22
  try {
17
23
  await browserNotificationClient.disable().catch(() => undefined);
18
24
  const response = await apiClient.auth.logout.$post();
@@ -25,18 +31,23 @@ export function createProfileActions(initialTheme: CloudTheme, appHref?: string)
25
31
  };
26
32
 
27
33
  const items = createMemo<DropdownItem[]>(() => [
28
- ...preferences.items(),
29
- {
30
- href: "/me",
31
- icon: "ti ti-user-circle",
32
- label: preferences.messages().profileSettings,
33
- },
34
- ...(appHref ? [{ href: appHref, icon: "ti ti-device-mobile", label: preferences.messages().app }] : []),
34
+ preferences.appearance(),
35
35
  {
36
- action: signOut,
37
- icon: "ti ti-logout",
38
- label: preferences.messages().signOut,
39
- disabled: signingOut(),
36
+ items: [
37
+ preferences.languageItem(),
38
+ {
39
+ href: "/me",
40
+ icon: "ti ti-user-circle",
41
+ label: preferences.messages().profileSettings,
42
+ },
43
+ ...(appHref ? [{ href: appHref, icon: "ti ti-device-mobile", label: preferences.messages().app }] : []),
44
+ {
45
+ action: signOut,
46
+ icon: "ti ti-logout",
47
+ label: preferences.messages().signOut,
48
+ disabled: signingOut(),
49
+ },
50
+ ],
40
51
  },
41
52
  ]);
42
53
 
@@ -12,8 +12,10 @@ export const profilePreferencesMessages = i18n.define({
12
12
  app: "App",
13
13
  signOut: "Sign out",
14
14
  signOutFailed: "Sign out failed. Please try again.",
15
- switchToDark: "Dark mode",
16
- switchToLight: "Light mode",
15
+ appearance: "Appearance",
16
+ themeLight: "Light",
17
+ themeDark: "Dark",
18
+ themeSystem: "Automatic",
17
19
  switchToGerman: "Deutsch",
18
20
  switchToEnglish: "English",
19
21
  },
@@ -26,8 +28,10 @@ export const profilePreferencesMessages = i18n.define({
26
28
  app: "App",
27
29
  signOut: "Abmelden",
28
30
  signOutFailed: "Abmelden fehlgeschlagen. Bitte versuche es erneut.",
29
- switchToDark: "Dunkler Modus",
30
- switchToLight: "Heller Modus",
31
+ appearance: "Darstellung",
32
+ themeLight: "Hell",
33
+ themeDark: "Dunkel",
34
+ themeSystem: "Automatisch",
31
35
  switchToGerman: "Deutsch",
32
36
  switchToEnglish: "English",
33
37
  },
@@ -1,26 +1,22 @@
1
1
  import type { RailPreferences, RailSnapshot } from "../contracts/rail-preferences";
2
2
  import type { RailApp } from "./rail-navigation";
3
+ import { readShellData } from "./shell-data";
3
4
 
4
5
  export type RailContext = { apps: RailApp[]; settings: RailSnapshot };
5
6
  export const RAIL_PREFERENCES_EVENT = "cloud:rail-preferences";
6
7
 
7
8
  export const readRailContext = (): RailContext | undefined => {
8
- if (typeof document === "undefined") return undefined;
9
- const text = document.getElementById("cloud-rail-data")?.textContent;
10
- if (!text) return undefined;
11
- // Framework-generated, escaped SSR data; never loaded from localStorage. The server validates the snapshot when
12
- // it reads it, so the shell does not load the schemas on every page.
13
- const value: RailContext = JSON.parse(text);
14
- return { apps: value.apps, settings: value.settings };
9
+ const data = readShellData();
10
+ return data?.rail ? { apps: data.apps, settings: data.rail } : undefined;
15
11
  };
16
12
 
17
13
  export const publishRailPreferences = (settings: RailPreferences) => {
18
- const context = readRailContext();
19
- const element = document.getElementById("cloud-rail-data");
20
- if (context && element)
14
+ const data = readShellData();
15
+ const element = document.getElementById("cloud-shell-data");
16
+ if (data?.rail && element)
21
17
  element.textContent = JSON.stringify({
22
- ...context,
23
- settings: { ...settings, managedShortcuts: context.settings.managedShortcuts },
18
+ ...data,
19
+ rail: { ...settings, managedShortcuts: data.rail.managedShortcuts },
24
20
  }).replace(/</g, "\\u003c");
25
21
  window.dispatchEvent(new CustomEvent(RAIL_PREFERENCES_EVENT));
26
22
  };
@@ -12,7 +12,7 @@ export type RailApp = {
12
12
  /** The app's `nav.badge` endpoint. */
13
13
  badge?: string;
14
14
  };
15
- export type RailLink = Omit<RailApp, "defaultVisible"> & { exact?: boolean };
15
+ export type RailLink = Omit<RailApp, "defaultVisible"> & { appId?: string; exact?: boolean };
16
16
 
17
17
  export const isRailAppVisible = (app: RailApp, settings: RailPreferences): boolean =>
18
18
  Object.hasOwn(settings.visibility, app.id) ? (settings.visibility[app.id] ?? app.defaultVisible) : app.defaultVisible;
@@ -32,7 +32,7 @@ export const projectRailNavigation = (apps: readonly RailApp[], settings: RailSn
32
32
  const app = catalog.get(shortcut.appId);
33
33
  if (!app || pinned.has(app.id)) continue;
34
34
  pinned.add(app.id);
35
- shortcuts.push({ ...app, id: shortcut.id });
35
+ shortcuts.push({ ...app, id: shortcut.id, appId: app.id });
36
36
  } else {
37
37
  shortcuts.push({
38
38
  id: shortcut.id,
@@ -46,7 +46,9 @@ export const projectRailNavigation = (apps: readonly RailApp[], settings: RailSn
46
46
  }
47
47
  return {
48
48
  shortcuts,
49
- apps: sortRailApps(apps, locale).filter((app) => !pinned.has(app.id) && isRailAppVisible(app, settings)),
49
+ apps: sortRailApps(apps, locale)
50
+ .filter((app) => !pinned.has(app.id) && isRailAppVisible(app, settings))
51
+ .map((app) => ({ ...app, appId: app.id })),
50
52
  };
51
53
  };
52
54
 
@@ -36,9 +36,14 @@ export const localizedAppName = (
36
36
  appId: string,
37
37
  fallback: string,
38
38
  ): string => {
39
+ return appNameIn(carrier, appId, getLocale(carrier as Parameters<typeof getLocale>[0]), fallback);
40
+ };
41
+
42
+ /** An app's registered name in the locale of a recorded request. */
43
+ export const appNameIn = (carrier: RuntimeCarrier, appId: string, locale: string, fallback: string): string => {
39
44
  const runtime = carrier.get("runtime");
40
45
  const apps =
41
46
  runtime && typeof runtime === "object" && Array.isArray((runtime as RuntimeContext).apps) ? (runtime as RuntimeContext).apps : [];
42
47
  const app = apps.find((entry) => entry.id === appId);
43
- return app ? resolveAppPresentation(app, getLocale(carrier as Parameters<typeof getLocale>[0])).name : fallback;
48
+ return app ? resolveAppPresentation(app, locale).name : fallback;
44
49
  };