@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,21 +1,15 @@
1
- import { Button, Dropdown, useLocale } from "@k2b/ui";
1
+ import { Dropdown, useLocale } from "@k2b/ui";
2
2
  import { canonicalLocale } from "../shared/locale";
3
3
  import { type ProfilePreferenceLocale, profilePreferenceLocale, setLocalePreference } from "../shared/locale-preference";
4
- import type { CloudTheme } from "../shared/theme";
4
+ import type { CloudThemePreference } from "../shared/theme";
5
5
  import { createPreferenceController } from "./preference-controller";
6
6
 
7
- /**
8
- * Labeled language and theme controls for the MinimalLayout footer. The
9
- * language trigger names the current language; the theme button names the
10
- * mode it switches to, like every other Cloud preference menu. Below `md` both
11
- * are compact, the language code and the theme icon with the full names as
12
- * accessible labels, so they fit next to the legal links on a phone. CSS picks
13
- * the form, so the server response is already final.
14
- */
15
- export default function MinimalLayoutPreferences(props: { initialTheme: CloudTheme }) {
7
+ /** Both menus name the current choice; compact triggers keep accessible names below md. */
8
+ export default function MinimalLayoutPreferences(props: { initialTheme: CloudThemePreference }) {
16
9
  const locale = useLocale();
17
10
  const preferences = createPreferenceController(props.initialTheme, locale);
18
11
  const t = preferences.messages;
12
+ const currentTheme = () => preferences.themeOption(preferences.theme());
19
13
  const language = () => profilePreferenceLocale(locale());
20
14
  const languageName = () => (language() === "de" ? t().switchToGerman : t().switchToEnglish);
21
15
  // Keep a regional locale such as en-GB when its language is chosen again,
@@ -43,10 +37,13 @@ export default function MinimalLayoutPreferences(props: { initialTheme: CloudThe
43
37
  <i class="ti ti-chevron-down hidden md:inline" aria-hidden="true" />
44
38
  </Dropdown.Trigger>
45
39
  </Dropdown.Root>
46
- <Button aria-label={preferences.themeLabel()} onClick={preferences.toggleTheme} size="sm" variant="ghost">
47
- <i class={preferences.theme() === "light" ? "ti ti-moon" : "ti ti-sun-high"} aria-hidden="true" />
48
- <span class="hidden md:inline">{preferences.themeLabel()}</span>
49
- </Button>
40
+ <Dropdown.Root label={t().appearance} position="top-right" width="12rem" items={preferences.appearance().items}>
41
+ <Dropdown.Trigger label={`${t().appearance}: ${currentTheme().label}`} size="sm" tooltip={false} variant="ghost">
42
+ <i class={currentTheme().icon} aria-hidden="true" />
43
+ <span class="hidden md:inline">{currentTheme().label}</span>
44
+ <i class="ti ti-chevron-down hidden md:inline" aria-hidden="true" />
45
+ </Dropdown.Trigger>
46
+ </Dropdown.Root>
50
47
  </div>
51
48
  );
52
49
  }
@@ -1,8 +1,10 @@
1
1
  import { BottomSheet, Navigation, SegmentedControl, useLocale } from "@k2b/ui";
2
2
  import { createSignal, onCleanup, Show } from "solid-js";
3
3
  import { type AppLaunchpadContext, AppLaunchpadPanel } from "./AppLaunchpadPanel";
4
+ import { appAccentStyle } from "./app-appearance";
4
5
  import { openMobileMenu } from "./mobile-menu-history";
5
6
  import { platformMessages } from "./platform-messages";
7
+ import { readShellData } from "./shell-data";
6
8
  import { observeWorkspaceNavigation, readWorkspaceNavigation } from "./workspace-navigation";
7
9
 
8
10
  export function openCloudMobileMenu(apps: AppLaunchpadContext) {
@@ -58,14 +60,17 @@ function MobileNavigation(props: {
58
60
  when={workspace() && tab() === "app"}
59
61
  fallback={<AppLaunchpadPanel surface="sheet" {...props.apps} close={props.close} beforeSelect={props.beforeSelect} />}
60
62
  >
61
- <Navigation
62
- navigation={workspace()!.navigation}
63
- label={workspace()!.label || t().appMenu}
64
- beforeSelect={async () => {
65
- const owner = workspace()?.owner;
66
- return (await props.beforeSelect()) && Boolean(owner?.isConnected && readWorkspaceNavigation()?.owner === owner);
67
- }}
68
- />
63
+ {/* The sheet opens outside the app canvas, so the app menu takes the app's accent here, as the canvas does. */}
64
+ <div class="app-accent-scope" style={appAccentStyle(readShellData()?.accent)}>
65
+ <Navigation
66
+ navigation={workspace()!.navigation}
67
+ label={workspace()!.label || t().appMenu}
68
+ beforeSelect={async () => {
69
+ const owner = workspace()?.owner;
70
+ return (await props.beforeSelect()) && Boolean(owner?.isConnected && readWorkspaceNavigation()?.owner === owner);
71
+ }}
72
+ />
73
+ </div>
69
74
  </Show>
70
75
  </BottomSheet.Body>
71
76
  </BottomSheet>
@@ -1,10 +1,10 @@
1
1
  import { documentNavigate } from "@k2b/ssr/nav";
2
- import { Button, ButtonLink } from "@k2b/ui";
3
- import type { CloudTheme } from "../shared/theme";
2
+ import { Button, ButtonLink, SegmentedControl } from "@k2b/ui";
3
+ import type { CloudThemePreference } from "../shared/theme";
4
4
  import { createProfileActions } from "./profile-actions";
5
5
 
6
6
  export function MobileProfileActions(props: {
7
- profile: { name: string; theme: CloudTheme; appHref?: string };
7
+ profile: { name: string; theme: CloudThemePreference; appHref?: string };
8
8
  beforeSelect: () => Promise<boolean>;
9
9
  }) {
10
10
  const { preferences, signingOut, signOut } = createProfileActions(props.profile.theme, props.profile.appHref);
@@ -17,6 +17,13 @@ export function MobileProfileActions(props: {
17
17
  };
18
18
  return (
19
19
  <section class="cloud-mobile-profile" aria-label={preferences.messages().menuLabel}>
20
+ <SegmentedControl
21
+ class="cloud-mobile-profile__appearance"
22
+ ariaLabel={preferences.messages().appearance}
23
+ options={preferences.themeOptions()}
24
+ value={preferences.theme}
25
+ onValueChange={preferences.setTheme}
26
+ />
20
27
  <ButtonLink href="/me" variant="ghost" onClick={navigate("/me")}>
21
28
  <i class="ti ti-user-circle" aria-hidden="true" />
22
29
  {preferences.messages().profileSettings}
@@ -27,10 +34,6 @@ export function MobileProfileActions(props: {
27
34
  {preferences.messages().app}
28
35
  </ButtonLink>
29
36
  )}
30
- <Button variant="ghost" onClick={preferences.toggleTheme}>
31
- <i class={`ti ${preferences.theme() === "light" ? "ti-moon" : "ti-sun-high"}`} aria-hidden="true" />
32
- {preferences.themeLabel()}
33
- </Button>
34
37
  <Button
35
38
  variant="ghost"
36
39
  onClick={async () => {
@@ -0,0 +1,14 @@
1
+ import { PageTitle, type PageTrailState } from "@k2b/ui";
2
+
3
+ /**
4
+ * The shell's own place for the way up, above pages without a workspace. It
5
+ * shows only on a page with a page above it, and steps back when the page
6
+ * renders a workspace (whose head shows it) or its own `PageTrail`.
7
+ */
8
+ export default function PageTrailRow(props: { trail: PageTrailState }) {
9
+ return (
10
+ <div class="layout-page-head" data-trail={props.trail.ancestors.length ? "up" : "title"}>
11
+ <PageTitle trail={props.trail} as="p" fallback />
12
+ </div>
13
+ );
14
+ }
@@ -1,12 +1,12 @@
1
- import { Avatar, Dropdown } from "@k2b/ui";
2
- import type { CloudTheme } from "../shared/theme";
1
+ import { Avatar, Dropdown, SegmentedControl } from "@k2b/ui";
2
+ import type { CloudThemePreference } from "../shared/theme";
3
3
  import { createProfileActions } from "./profile-actions";
4
4
 
5
5
  type ProfilePreferencesProps = {
6
6
  /** Set while the installation offers the mobile app. */
7
7
  appHref?: string;
8
8
  avatarSrc?: string;
9
- initialTheme: CloudTheme;
9
+ initialTheme: CloudThemePreference;
10
10
  name: string;
11
11
  };
12
12
 
@@ -26,10 +26,15 @@ export default function ProfilePreferences(props: ProfilePreferencesProps) {
26
26
  {avatar()}
27
27
  </button>
28
28
  <div class="layout-profile-preferences__panel dropdown-menu-surface">
29
- <button type="button" class="menu-item" onClick={preferences.toggleTheme}>
30
- <i class={`ti ${preferences.theme() === "light" ? "ti-moon" : "ti-sun-high"}`} aria-hidden="true" />
31
- <span>{preferences.themeLabel()}</span>
32
- </button>
29
+ <div class="layout-profile-preferences__appearance">
30
+ <SegmentedControl
31
+ size="sm"
32
+ ariaLabel={preferences.messages().appearance}
33
+ options={preferences.themeOptions().map(({ value, label }) => ({ value, label }))}
34
+ value={preferences.theme}
35
+ onValueChange={preferences.setTheme}
36
+ />
37
+ </div>
33
38
  <button type="button" class="menu-item" onClick={preferences.toggleLanguage}>
34
39
  <i class="ti ti-language" aria-hidden="true" />
35
40
  <span>{preferences.languageLabel()}</span>
@@ -2,7 +2,6 @@ import { LocaleProvider, MobileShell, TabBar, type TabBarItem } from "@k2b/ui";
2
2
  import type { JSX } from "solid-js";
3
3
  import { PWA_SCOPE } from "../contracts/pwa";
4
4
  import { getLocale } from "../server/locale";
5
- import { readThemeFromCookieHeader } from "../shared/theme";
6
5
  import { visiblePwaParts } from "./app-navigation";
7
6
  import type { LayoutContext } from "./layout-context";
8
7
  import PwaRuntime from "./PwaRuntime.island";
@@ -37,7 +36,6 @@ export default function PwaLayout(props: PwaLayoutProps) {
37
36
  const user = c.get("user");
38
37
  const cloud = c.get("settings")?.app?.name || "Cloud";
39
38
  const page = c.get("page");
40
- page.theme = readThemeFromCookieHeader(c.req.raw.headers.get("Cookie"));
41
39
  page.title = props.title;
42
40
  page.pwa = { name: cloud };
43
41
  // No app page may be framed, and none leaks its address to another site.
@@ -7,7 +7,7 @@ import { pwaMessages } from "./pwa-messages";
7
7
  /** Set once per browsing session, so the first page renews and later navigations do not. */
8
8
  const RENEWED_MARKER = "cloud.pwa.renewed";
9
9
  const KEEPALIVE_INTERVAL_MS = 60 * 60 * 1000;
10
- /** Matches `cloud:theme-preference` in the preference controller. */
10
+ /** Fires for preference changes and system scheme changes from the head script. */
11
11
  const THEME_PREFERENCE_EVENT = "cloud:theme-preference";
12
12
 
13
13
  export type PwaRuntimeProps = {
@@ -10,7 +10,7 @@ import { projectRailNavigation, type RailApp, type RailLink, railLinkActive } fr
10
10
  function RailIcon(props: { link: RailLink; currentPath: string }) {
11
11
  let target: HTMLAnchorElement | undefined;
12
12
  const active = () => railLinkActive(props.link, props.currentPath);
13
- const badge = useAppBadgeDescription(() => props.link.badge);
13
+ const badge = useAppBadgeDescription(() => ({ endpoint: props.link.badge, appId: props.link.appId }));
14
14
  return (
15
15
  <>
16
16
  <a
@@ -23,7 +23,7 @@ function RailIcon(props: { link: RailLink; currentPath: string }) {
23
23
  >
24
24
  <i class={`${props.link.iconClass} text-base`} aria-hidden="true" />
25
25
  <span class="sr-only">{props.link.label}</span>
26
- <AppBadge endpoint={props.link.badge} />
26
+ <AppBadge endpoint={props.link.badge} appId={props.link.appId} />
27
27
  </a>
28
28
  <Tooltip target={() => target} content={props.link.label} placement="right" delay={0} />
29
29
  </>
@@ -0,0 +1,170 @@
1
+ import type { HelpDocumentManifest } from "@k2b/cloud/shared";
2
+ import {
3
+ followPageTrail,
4
+ installAppWorkspaceController,
5
+ installPageTitleController,
6
+ serializeAppWorkspaceLayoutState,
7
+ useLocale,
8
+ } from "@k2b/ui";
9
+ import { createEffect, onCleanup, onMount } from "solid-js";
10
+ import { appWorkspaceCookieName, readAppWorkspaceLayoutCookie } from "../_internal/app-workspace-state";
11
+ import { registerContextAwareCommand } from "../browser/command-bridge";
12
+ import { attachCommandShortcuts } from "../browser/command-shortcuts";
13
+ import { navigationSearchItems } from "../browser/navigation-search";
14
+ import { importOnDemand } from "../browser/reload";
15
+ import { registerGlobalSearchHost } from "../browser/search-bridge";
16
+ import {
17
+ ANNOUNCEMENTS_COOKIE,
18
+ ANNOUNCEMENTS_COOKIE_MAX_AGE_SECONDS,
19
+ mergeAnnouncementCookieState,
20
+ parseAnnouncementCookieHeader,
21
+ serializeAnnouncementCookieState,
22
+ } from "../contracts/announcement-cookie";
23
+ import type { AnnouncementDisplayEntry } from "../contracts/announcements";
24
+ import { createGlobalSearchHost } from "./global-search-host";
25
+ import { LayoutHelpDocuments } from "./layout-help-registry";
26
+ import { installPageTrailRuntime } from "./page-trail-runtime";
27
+ import { platformMessages } from "./platform-messages";
28
+ import { readShellData } from "./shell-data";
29
+ import { syncTimezoneCookie } from "./timezone-cookie";
30
+
31
+ export type ShellRuntimeProps = {
32
+ /** Layout pages outside focus mode: keep the current app's workspace layout in its cookie. */
33
+ workspace?: { appId?: string };
34
+ /** Layout pages: page titles, the way up (Alt+Up) and its per-tab memory. */
35
+ pageTrail?: boolean;
36
+ /** The current app's Help documents. */
37
+ help?: { documents: readonly HelpDocumentManifest[]; pageBase: string };
38
+ /** Layout pages outside focus mode: the search host, Cmd/Ctrl+K, the Help command (Shift+/) and command shortcuts. */
39
+ commands?: { searchResources: boolean };
40
+ /** Signed-in pages: refresh this browser's push endpoint. */
41
+ push?: boolean;
42
+ /** Announcements the person has not seen yet; a dialog shows them once. */
43
+ announcements?: { entries: AnnouncementDisplayEntry[]; latestVersion: number };
44
+ /** Banners rendered by Layout: their dismiss buttons record the dismissal. */
45
+ banners?: boolean;
46
+ };
47
+
48
+ /** The search rows of the shell's destinations; the page carries each link once, without the fields a row repeats. */
49
+ const searchItems = () => {
50
+ const data = readShellData();
51
+ if (!data) return [];
52
+ const apps = Object.entries(data.searchLinks).flatMap(([id, searchLinks]) => {
53
+ const app = data.apps.find((candidate) => candidate.id === id);
54
+ return app ? [{ id, name: app.label, icon: app.iconClass, searchLinks }] : [];
55
+ });
56
+ return navigationSearchItems(
57
+ apps,
58
+ apps.map((app) => app.id),
59
+ );
60
+ };
61
+
62
+ export default function ShellRuntime(props: ShellRuntimeProps) {
63
+ const locale = useLocale();
64
+ const t = () => platformMessages.resolve([locale()]).t;
65
+ onMount(() => syncTimezoneCookie(true));
66
+
67
+ if (props.workspace) {
68
+ onMount(() => {
69
+ const appId = props.workspace?.appId;
70
+ onCleanup(
71
+ installAppWorkspaceController({
72
+ readState: () => readAppWorkspaceLayoutCookie(document.cookie, appId),
73
+ writeState: (state) => {
74
+ if (!appId) return;
75
+ const secure = window.location.protocol === "https:" ? "; Secure" : "";
76
+ document.cookie = `${appWorkspaceCookieName(appId)}=${serializeAppWorkspaceLayoutState(state)}; Path=/; Max-Age=31536000; SameSite=Lax${secure}`;
77
+ },
78
+ }),
79
+ );
80
+ });
81
+ }
82
+ if (props.pageTrail) {
83
+ onMount(() => {
84
+ const messages = t();
85
+ const disposeTitles = installPageTitleController(document);
86
+ const disposeFollow = followPageTrail(document);
87
+ const disposeRuntime = installPageTrailRuntime((page) => ({
88
+ title: messages.pageUp({ page }),
89
+ description: messages.pageUpDescription,
90
+ }));
91
+ onCleanup(() => {
92
+ disposeTitles();
93
+ disposeFollow();
94
+ disposeRuntime();
95
+ });
96
+ });
97
+ }
98
+ if (props.commands) {
99
+ const host = createGlobalSearchHost(searchItems, props.commands.searchResources);
100
+ onMount(() => onCleanup(registerGlobalSearchHost(host.open, host.dispose)));
101
+ // Command schemas and the capability client load with the first shortcut.
102
+ onMount(() =>
103
+ onCleanup(
104
+ attachCommandShortcuts(
105
+ (command) =>
106
+ void importOnDemand(() => import("../browser/commands")).then((commands) => commands?.runContextAwareCommand(command)),
107
+ ),
108
+ ),
109
+ );
110
+ createEffect(() =>
111
+ onCleanup(
112
+ registerContextAwareCommand({
113
+ id: "cloud.search",
114
+ title: t().openGlobalSearch,
115
+ description: t().globalSearchDescription,
116
+ icon: "ti ti-search",
117
+ shortcut: "mod+k",
118
+ action: { search: {} },
119
+ }),
120
+ ),
121
+ );
122
+ createEffect(() =>
123
+ onCleanup(
124
+ registerContextAwareCommand({
125
+ id: "cloud.help",
126
+ title: t().openShortcutHelp,
127
+ description: t().shortcutHelpDescription,
128
+ icon: "ti ti-help-circle",
129
+ shortcut: "shift+/",
130
+ // Returning the load keeps the Command busy until the dialog is open; a second press opens no second dialog.
131
+ action: () =>
132
+ importOnDemand(() => import("./LayoutHelp")).then((help) => {
133
+ const data = readShellData();
134
+ help?.openLayoutHelpDialog(data?.searchHelpApps ?? [], data?.accent);
135
+ }),
136
+ }),
137
+ ),
138
+ );
139
+ }
140
+ onMount(() => {
141
+ if (props.push) {
142
+ void import("../browser/notifications")
143
+ .then(({ browserNotificationClient }) => browserNotificationClient.refreshExisting())
144
+ .catch((error) => {
145
+ console.warn("[notifications] Failed to refresh the browser endpoint", error);
146
+ });
147
+ }
148
+ if (props.announcements?.entries.length) {
149
+ const announcements = props.announcements;
150
+ void importOnDemand(() => import("./announcements-dialog")).then((loaded) => loaded?.openAnnouncementsDialog(announcements));
151
+ }
152
+ if (props.banners) {
153
+ const dismiss = (event: MouseEvent) => {
154
+ if (!(event.target instanceof Element)) return;
155
+ const button = event.target.closest<HTMLButtonElement>("[data-cloud-announcement-dismiss]");
156
+ if (!button) return;
157
+ const version = Number(button.dataset.cloudAnnouncementDismiss);
158
+ const next = mergeAnnouncementCookieState(parseAnnouncementCookieHeader(document.cookie), { dismissedBannerVersions: [version] });
159
+ document.cookie = `${ANNOUNCEMENTS_COOKIE}=${serializeAnnouncementCookieState(next)}; Path=/; Max-Age=${ANNOUNCEMENTS_COOKIE_MAX_AGE_SECONDS}; SameSite=Lax`;
160
+ const banner = button.closest(".cloud-announcement");
161
+ const list = banner?.parentElement;
162
+ banner?.remove();
163
+ if (list && !list.querySelector(".cloud-announcement")) list.remove();
164
+ };
165
+ document.addEventListener("click", dismiss);
166
+ onCleanup(() => document.removeEventListener("click", dismiss));
167
+ }
168
+ });
169
+ return props.help ? <LayoutHelpDocuments documents={props.help.documents} pageBase={props.help.pageBase} /> : null;
170
+ }
@@ -1,29 +1,13 @@
1
- import { cookies } from "@k2b/stdlib/browser";
2
1
  import { onMount } from "solid-js";
3
- import { TIMEZONE_COOKIE } from "../shared/time";
2
+ import { syncTimezoneCookie } from "./timezone-cookie";
4
3
 
5
4
  export type TimezoneCookieProps = {
6
5
  /** Reload once after a timezone change, so the page shows times in the browser's zone. Defaults to `true`. */
7
6
  reload?: boolean;
8
7
  };
9
8
 
9
+ /** The timezone cookie for layouts without the shell runtime: `MinimalLayout` and `PwaLayout`. */
10
10
  export default function TimezoneCookie(props: TimezoneCookieProps) {
11
- onMount(() => {
12
- const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
13
- const current = cookies.readCookie(TIMEZONE_COOKIE);
14
- if (timeZone && current !== timeZone) {
15
- cookies.writeCookie(TIMEZONE_COOKIE, timeZone);
16
- if (props.reload === false) return;
17
- try {
18
- if (sessionStorage.getItem("cloud.timezone.reload") !== timeZone) {
19
- sessionStorage.setItem("cloud.timezone.reload", timeZone);
20
- window.location.reload();
21
- }
22
- } catch {
23
- // Cookie persistence still succeeds; the next navigation will render with the browser timezone.
24
- }
25
- }
26
- });
27
-
11
+ onMount(() => syncTimezoneCookie(props.reload !== false));
28
12
  return null;
29
13
  }
@@ -5,12 +5,31 @@ import { provideWorkspaceNavigation } from "./workspace-navigation";
5
5
  export default function WorkspaceNavigationProvider(props: { navigation: NavigationController; label: string }) {
6
6
  let owner!: HTMLScriptElement;
7
7
  provideWorkspaceNavigation(props.navigation, { label: () => props.label, owner: () => owner });
8
+ const snapshot = () => {
9
+ const switcher = props.navigation.switcher?.();
10
+ return {
11
+ label: props.label,
12
+ items: props.navigation.items(),
13
+ ...(switcher
14
+ ? {
15
+ switcher: {
16
+ currentId: switcher.currentId,
17
+ label: switcher.label,
18
+ description: switcher.description,
19
+ icon: switcher.icon,
20
+ color: switcher.color,
21
+ switchLabel: switcher.switchLabel,
22
+ },
23
+ }
24
+ : {}),
25
+ };
26
+ };
8
27
  return (
9
28
  <script
10
29
  ref={owner}
11
30
  type="application/json"
12
31
  data-cloud-workspace-navigation
13
- innerHTML={JSON.stringify({ label: props.label, items: props.navigation.items() }).replace(/</g, "\\u003c")}
32
+ innerHTML={JSON.stringify(snapshot()).replace(/</g, "\\u003c")}
14
33
  />
15
34
  );
16
35
  }
@@ -0,0 +1,66 @@
1
+ import { Button, dialogCore, MarkdownView, PanelDialog, panelDialogOptions, useLocale } from "@k2b/ui";
2
+ import { For } from "solid-js";
3
+ import {
4
+ ANNOUNCEMENTS_COOKIE,
5
+ ANNOUNCEMENTS_COOKIE_MAX_AGE_SECONDS,
6
+ mergeAnnouncementCookieState,
7
+ parseAnnouncementCookieHeader,
8
+ serializeAnnouncementCookieState,
9
+ } from "../contracts/announcement-cookie";
10
+ import type { AnnouncementDisplayEntry } from "../contracts/announcements";
11
+ import { platformMessages } from "./platform-messages";
12
+
13
+ function AnnouncementsPanel(props: { entries: AnnouncementDisplayEntry[]; close: () => void }) {
14
+ const locale = useLocale();
15
+ const t = () => platformMessages.resolve([locale()]).t;
16
+ return (
17
+ <PanelDialog>
18
+ <PanelDialog.Header
19
+ title={t().announcements}
20
+ subtitle={t().latestPlatformUpdates}
21
+ icon="ti ti-speakerphone text-blue-500"
22
+ close={props.close}
23
+ />
24
+ <PanelDialog.Body>
25
+ <For each={props.entries}>
26
+ {(entry) => (
27
+ <article class="panel-dialog-section rounded-[var(--ui-radius-surface)] p-4">
28
+ <div class="mb-3 flex items-start justify-between gap-3">
29
+ <div class="min-w-0">
30
+ <h2 class="text-base font-semibold text-primary">{entry.title}</h2>
31
+ <p class="mt-0.5 text-xs text-dimmed">
32
+ {new Date(entry.publishedAt).toLocaleDateString(locale(), {
33
+ year: "numeric",
34
+ month: "short",
35
+ day: "numeric",
36
+ })}
37
+ </p>
38
+ </div>
39
+ <span class="rounded bg-zinc-100 px-1.5 py-0.5 text-[10px] font-medium text-dimmed dark:bg-zinc-800">v{entry.version}</span>
40
+ </div>
41
+ <MarkdownView trustedHtml={entry.bodyHtml} />
42
+ </article>
43
+ )}
44
+ </For>
45
+ </PanelDialog.Body>
46
+ <PanelDialog.Footer>
47
+ <span />
48
+ <Button size="sm" onClick={props.close}>
49
+ {t().gotIt}
50
+ </Button>
51
+ </PanelDialog.Footer>
52
+ </PanelDialog>
53
+ );
54
+ }
55
+
56
+ /** Shows the announcements the person has not seen yet, and records them as seen once the dialog closes. */
57
+ export function openAnnouncementsDialog(props: { entries: AnnouncementDisplayEntry[]; latestVersion: number }) {
58
+ void dialogCore
59
+ .open<void>((close) => <AnnouncementsPanel entries={props.entries} close={() => close()} />, panelDialogOptions)
60
+ .then(() => {
61
+ const next = mergeAnnouncementCookieState(parseAnnouncementCookieHeader(document.cookie), {
62
+ seenAnnouncementVersion: props.latestVersion,
63
+ });
64
+ document.cookie = `${ANNOUNCEMENTS_COOKIE}=${serializeAnnouncementCookieState(next)}; Path=/; Max-Age=${ANNOUNCEMENTS_COOKIE_MAX_AGE_SECONDS}; SameSite=Lax`;
65
+ });
66
+ }
@@ -1,5 +1,7 @@
1
1
  import { createSignal } from "solid-js";
2
2
  import { APP_BADGES_REFRESH_EVENT } from "../browser/app-badges";
3
+ import { APPROVAL_COUNTS_PATH } from "../contracts/approval-paths";
4
+ import type { ApprovalCounts } from "../contracts/approvals";
3
5
 
4
6
  /**
5
7
  * Shortest time between two scheduled reads in one tab, and the longest one
@@ -13,8 +15,20 @@ export const APP_BADGE_INTERVAL_MS = 60_000;
13
15
  // count. Only the browser ever writes it; server renders always see no badge.
14
16
  const [counts, setCounts] = createSignal<ReadonlyMap<string, number>>(new Map());
15
17
 
16
- /** The count for a declared `nav.badge` endpoint; 0 until the browser has read it. */
17
- export const appBadgeCount = (endpoint: string | undefined): number => (endpoint ? (counts().get(endpoint) ?? 0) : 0);
18
+ const [approvalCounts, setApprovalCounts] = createSignal<ApprovalCounts | null>(null);
19
+
20
+ /** Retain an SSR count until the first browser read; a failed read still resolves to zero. */
21
+ export const liveApprovalCount = (appId?: string): number | null => {
22
+ const current = approvalCounts();
23
+ if (!current) return null;
24
+ if (appId === undefined) return current.total;
25
+ return Object.hasOwn(current.apps, appId) ? (current.apps[appId] ?? 0) : 0;
26
+ };
27
+ export const approvalCount = (appId?: string): number => liveApprovalCount(appId) ?? 0;
28
+
29
+ /** Endpoint and approval counts for one app; 0 until the browser has read them. */
30
+ export const appBadgeCount = (input: { endpoint?: string; appId?: string }): number =>
31
+ (input.endpoint ? (counts().get(input.endpoint) ?? 0) : 0) + (input.appId ? approvalCount(input.appId) : 0);
18
32
 
19
33
  /** A positive safe integer from an `AppNavBadge` body, else 0. */
20
34
  export const parseAppBadge = (body: unknown): number => {
@@ -33,6 +47,33 @@ const readBadge = async (endpoint: string, signal: AbortSignal): Promise<number>
33
47
  }
34
48
  };
35
49
 
50
+ const isCount = (value: unknown, minimum: number): value is number =>
51
+ typeof value === "number" && Number.isSafeInteger(value) && value >= minimum;
52
+
53
+ /** What `ApprovalCountsSchema` accepts from a JSON body, without loading validators into the shell; a test compares both. */
54
+ export const parseApprovalCounts = (body: unknown): ApprovalCounts | null => {
55
+ if (typeof body !== "object" || body === null || Array.isArray(body)) return null;
56
+ if (Object.keys(body).length !== 2 || !("total" in body) || !("apps" in body)) return null;
57
+ const { total, apps } = body;
58
+ if (!isCount(total, 0) || typeof apps !== "object" || apps === null || Array.isArray(apps)) return null;
59
+ // Like Zod's record, ignore a `__proto__` key that JSON.parse keeps as an own property.
60
+ const entries = Object.entries(apps).filter(([app]) => app !== "__proto__");
61
+ return entries.every(([, count]) => isCount(count, 1)) ? { total, apps: Object.fromEntries(entries) } : null;
62
+ };
63
+
64
+ const readApprovalCounts = async (signal: AbortSignal): Promise<ApprovalCounts> => {
65
+ try {
66
+ const response = await fetch(APPROVAL_COUNTS_PATH, { credentials: "same-origin", headers: { accept: "application/json" }, signal });
67
+ if (response.status === 200) {
68
+ const parsed = parseApprovalCounts(await response.json());
69
+ if (parsed) return parsed;
70
+ }
71
+ } catch {
72
+ // A failing read clears approval counts just like an app badge.
73
+ }
74
+ return { total: 0, apps: {} };
75
+ };
76
+
36
77
  let watching = false;
37
78
 
38
79
  /**
@@ -45,8 +86,8 @@ let watching = false;
45
86
  * read still running after the interval gives up. Returns a disposer.
46
87
  */
47
88
  export const watchAppBadges = (endpoints: readonly string[]): (() => void) => {
48
- const unique = [...new Set(endpoints)];
49
- if (typeof window === "undefined" || unique.length === 0 || watching) return () => {};
89
+ const unique = [...new Set([...endpoints, APPROVAL_COUNTS_PATH])];
90
+ if (typeof window === "undefined" || watching) return () => {};
50
91
  watching = true;
51
92
  const stopped = new AbortController();
52
93
  // Endpoints with a read in flight, mapped to whether one more read waits for it.
@@ -60,9 +101,12 @@ export const watchAppBadges = (endpoints: readonly string[]): (() => void) => {
60
101
  return;
61
102
  }
62
103
  running.set(endpoint, false);
63
- void readBadge(endpoint, AbortSignal.any([stopped.signal, AbortSignal.timeout(APP_BADGE_INTERVAL_MS)])).then((count) => {
104
+ const signal = AbortSignal.any([stopped.signal, AbortSignal.timeout(APP_BADGE_INTERVAL_MS)]);
105
+ const result = endpoint === APPROVAL_COUNTS_PATH ? readApprovalCounts(signal) : readBadge(endpoint, signal);
106
+ void result.then((count) => {
64
107
  if (stopped.signal.aborted) return;
65
- setCounts((previous) => new Map(previous).set(endpoint, count));
108
+ if (typeof count === "number") setCounts((previous) => new Map(previous).set(endpoint, count));
109
+ else setApprovalCounts(count);
66
110
  const queued = running.get(endpoint);
67
111
  running.delete(endpoint);
68
112
  if (queued && document.visibilityState !== "hidden") read(endpoint);