@k2b/cloud 0.32.0 → 0.34.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 (113) hide show
  1. package/package.json +5 -5
  2. package/src/_internal/app-presentation.ts +40 -11
  3. package/src/_internal/define-app.ts +18 -8
  4. package/src/_internal/page-responses.ts +5 -1
  5. package/src/_internal/registry.ts +10 -1
  6. package/src/_internal/widget-declarations.ts +16 -3
  7. package/src/_internal/widget-settings.ts +93 -0
  8. package/src/ai/capabilities.ts +8 -5
  9. package/src/ai/code-source-contracts.ts +17 -2
  10. package/src/ai/code-source-tools.ts +1 -0
  11. package/src/ai/default-tools.ts +4 -4
  12. package/src/ai/memories.ts +48 -1
  13. package/src/ai/memory-learning.ts +17 -6
  14. package/src/ai/memory-tool.ts +1 -0
  15. package/src/ai/migrate.ts +8 -0
  16. package/src/ai/provider.ts +4 -3
  17. package/src/ai/resource-refs.ts +35 -3
  18. package/src/ai/system-prompt.ts +3 -2
  19. package/src/api/index.ts +2 -0
  20. package/src/api/mcp.ts +48 -17
  21. package/src/api/pwa.ts +7 -2
  22. package/src/api/resource-links.ts +43 -0
  23. package/src/api/widgets.ts +163 -82
  24. package/src/browser/CloudResourceSearch.tsx +6 -6
  25. package/src/browser/command-shortcuts.ts +2 -11
  26. package/src/browser/navigation-search.ts +4 -1
  27. package/src/browser/resource-picker.tsx +1 -0
  28. package/src/browser/resource-search.ts +7 -2
  29. package/src/browser/widgets.ts +23 -5
  30. package/src/capabilities/testing.ts +13 -1
  31. package/src/cli/capabilities.ts +61 -17
  32. package/src/cli/index.ts +2 -0
  33. package/src/cli/resource-links.ts +13 -0
  34. package/src/contracts/app.ts +7 -1
  35. package/src/contracts/capabilities.ts +25 -5
  36. package/src/contracts/registry.ts +3 -1
  37. package/src/contracts/resource-href.ts +7 -0
  38. package/src/contracts/widgets.ts +80 -6
  39. package/src/desktop/index.ts +3 -2
  40. package/src/desktop/solid.tsx +7 -5
  41. package/src/server/widget-request.ts +34 -5
  42. package/src/services/app-approval.ts +4 -2
  43. package/src/services/approvals/index.ts +40 -11
  44. package/src/services/approvals/list.ts +1 -0
  45. package/src/services/approvals/migrate.ts +10 -0
  46. package/src/services/outbox.ts +13 -6
  47. package/src/services/pdf/markdown.ts +11 -7
  48. package/src/services/pwa-devices.ts +68 -15
  49. package/src/shared/ai-platform-prompt.ts +3 -2
  50. package/src/shared/app-presentation.ts +49 -1
  51. package/src/shared/index.ts +1 -0
  52. package/src/shared/markdown/callout-sanitizer.ts +37 -0
  53. package/src/shared/markdown/index.ts +8 -2
  54. package/src/shared/theme.ts +41 -20
  55. package/src/ssr/AnnouncementBanners.tsx +30 -0
  56. package/src/ssr/AppLaunchpadPanel.tsx +2 -2
  57. package/src/ssr/CloudShell.island.tsx +91 -0
  58. package/src/ssr/GlobalSearchTrigger.tsx +51 -0
  59. package/src/ssr/Layout.tsx +58 -99
  60. package/src/ssr/LayoutHeader.tsx +31 -77
  61. package/src/ssr/LayoutHelp.browser-harness.tsx +43 -0
  62. package/src/ssr/LayoutHelp.tsx +319 -238
  63. package/src/ssr/LayoutHelpTrigger.tsx +49 -0
  64. package/src/ssr/LayoutPreferences.tsx +31 -0
  65. package/src/ssr/LayoutRail.tsx +15 -69
  66. package/src/ssr/MinimalLayout.tsx +2 -3
  67. package/src/ssr/MinimalLayoutPreferences.island.tsx +6 -15
  68. package/src/ssr/MobileNavigation.tsx +13 -8
  69. package/src/ssr/MobileProfileActions.tsx +4 -6
  70. package/src/ssr/PageTrailRow.tsx +14 -0
  71. package/src/ssr/{ProfilePreferences.island.tsx → ProfilePreferences.tsx} +8 -5
  72. package/src/ssr/PwaLayout.tsx +0 -2
  73. package/src/ssr/PwaRuntime.island.tsx +1 -1
  74. package/src/ssr/ShellRuntime.island.tsx +166 -0
  75. package/src/ssr/ThemeCycleButton.tsx +46 -0
  76. package/src/ssr/TimezoneCookie.island.tsx +3 -19
  77. package/src/ssr/WorkspaceNavigationProvider.tsx +20 -1
  78. package/src/ssr/announcements-dialog.tsx +66 -0
  79. package/src/ssr/app-appearance.ts +6 -1
  80. package/src/ssr/{AppLaunchpad.island.tsx → app-launchpad.tsx} +42 -62
  81. package/src/ssr/busy-action.ts +19 -0
  82. package/src/ssr/islands/index.ts +9 -6
  83. package/src/ssr/launchpad-surface.tsx +15 -0
  84. package/src/ssr/layout-help-registry.tsx +60 -0
  85. package/src/ssr/layout-help-url.ts +39 -0
  86. package/src/ssr/mobile-navigation.browser-harness.tsx +20 -0
  87. package/src/ssr/platform-messages.ts +0 -8
  88. package/src/ssr/preference-controller.ts +62 -19
  89. package/src/ssr/profile-actions.ts +12 -5
  90. package/src/ssr/profile-preferences-messages.ts +10 -4
  91. package/src/ssr/rail-context.ts +8 -12
  92. package/src/ssr/rail-navigation.ts +3 -0
  93. package/src/ssr/shell-data.tsx +48 -0
  94. package/src/ssr/timezone-cookie.ts +22 -0
  95. package/src/ssr/workspace-navigation.ts +25 -2
  96. package/src/styles/effects.css +13 -3
  97. package/src/styles/global.css +43 -5
  98. package/src/styles/input.css +0 -8
  99. package/src/styles/tokens.css +0 -2
  100. package/src/styles/utilities-navigation.css +10 -0
  101. package/src/workflows/language/compiler.ts +1 -1
  102. package/src/ssr/AppWorkspaceController.island.tsx +0 -21
  103. package/src/ssr/BrowserPushRegistration.island.tsx +0 -12
  104. package/src/ssr/GlobalAnnouncements.island.tsx +0 -117
  105. package/src/ssr/GlobalSearchHelpDialog.tsx +0 -104
  106. package/src/ssr/GlobalSearchTrigger.island.tsx +0 -75
  107. package/src/ssr/LayoutHelpTrigger.island.tsx +0 -61
  108. package/src/ssr/LayoutPreferences.island.tsx +0 -21
  109. package/src/ssr/PageTrailRow.island.tsx +0 -20
  110. package/src/ssr/PageTrailRuntime.island.tsx +0 -26
  111. package/src/ssr/RegisteredHelpDocuments.island.tsx +0 -3
  112. /package/src/ssr/{LayoutBreadcrumbs.island.tsx → LayoutBreadcrumbs.tsx} +0 -0
  113. /package/src/ssr/{RailApps.island.tsx → RailApps.tsx} +0 -0
@@ -0,0 +1,49 @@
1
+ import { IconButton, Tooltip, useLocale } from "@k2b/ui";
2
+ import { importOnDemand } from "../browser/reload";
3
+ import { createBusyAction } from "./busy-action";
4
+ import { platformMessages } from "./platform-messages";
5
+
6
+ type LayoutHelpTriggerProps = {
7
+ variant: "header" | "rail";
8
+ class?: string;
9
+ };
10
+
11
+ /** Opens end-user help from either the desktop rail or the compact header. */
12
+ export default function LayoutHelpTrigger(props: LayoutHelpTriggerProps) {
13
+ const locale = useLocale();
14
+ const t = () => platformMessages.resolve([locale()]).t;
15
+ const action = createBusyAction();
16
+ const openHelp = () =>
17
+ action.run(
18
+ () => importOnDemand(() => import("./LayoutHelp")),
19
+ (help) => help.openLayoutHelpDialog(),
20
+ );
21
+
22
+ if (props.variant === "rail")
23
+ return (
24
+ <Tooltip.Trigger
25
+ type="button"
26
+ class={`rail-item text-blue-500 hover:bg-blue-500/10 hover:text-blue-600 dark:text-blue-400 dark:hover:bg-blue-500/15 dark:hover:text-blue-300 ${props.class ?? ""}`}
27
+ onClick={openHelp}
28
+ aria-busy={action.busy() ? "true" : undefined}
29
+ aria-label={t().openHelp}
30
+ placement="right"
31
+ delay={0}
32
+ content={t().helpShortcut}
33
+ >
34
+ <i class={`${action.busy() ? "ti ti-loader-2 animate-spin" : "ti ti-help-circle"} text-base`} />
35
+ </Tooltip.Trigger>
36
+ );
37
+
38
+ return (
39
+ <IconButton
40
+ class={props.class}
41
+ onClick={openHelp}
42
+ aria-busy={action.busy() ? "true" : undefined}
43
+ label={t().openHelp}
44
+ title={t().helpShortcut}
45
+ >
46
+ <i class={`${action.busy() ? "ti ti-loader-2 animate-spin" : "ti ti-help-circle"} text-base`} />
47
+ </IconButton>
48
+ );
49
+ }
@@ -0,0 +1,31 @@
1
+ import { Dropdown, type DropdownPosition, useLocale } from "@k2b/ui";
2
+ import type { CloudThemePreference } from "../shared/theme";
3
+ import { createPreferenceController } from "./preference-controller";
4
+
5
+ type LayoutPreferencesProps = {
6
+ initialTheme: CloudThemePreference;
7
+ position: DropdownPosition;
8
+ };
9
+
10
+ export default function LayoutPreferences(props: LayoutPreferencesProps) {
11
+ const locale = useLocale();
12
+ const preferences = createPreferenceController(props.initialTheme, locale);
13
+
14
+ return (
15
+ <>
16
+ <Dropdown.Root
17
+ items={preferences.items()}
18
+ label={preferences.messages().preferencesMenuLabel}
19
+ position={props.position}
20
+ width="14rem"
21
+ >
22
+ <Dropdown.Trigger iconOnly label={preferences.messages().preferencesMenuLabel} size="sm" variant="secondary">
23
+ <i class="ti ti-adjustments-horizontal" aria-hidden="true" />
24
+ </Dropdown.Trigger>
25
+ </Dropdown.Root>
26
+ <span class="sr-only" aria-live="polite">
27
+ {preferences.themeAnnouncement()}
28
+ </span>
29
+ </>
30
+ );
31
+ }
@@ -1,75 +1,21 @@
1
- import type { NavigationSearchItem } from "../browser/navigation-search";
2
- import type { RailSnapshot } from "../contracts/rail-preferences";
3
- import type { CloudTheme } from "../shared/theme";
4
- import AppLaunchpad, { type AppLaunchpadApp } from "./AppLaunchpad.island";
5
- import type { GlobalSearchHelpApp } from "./GlobalSearchHelpDialog";
6
- import GlobalSearchTrigger from "./GlobalSearchTrigger.island";
7
- import LayoutHelpTrigger from "./LayoutHelpTrigger.island";
8
- import ProfilePreferences from "./ProfilePreferences.island";
9
- import RailApps from "./RailApps.island";
1
+ import { useLocale } from "@k2b/ui";
2
+ import CloudShell from "./CloudShell.island";
3
+ import { platformMessages } from "./platform-messages";
10
4
  import type { RailApp } from "./rail-navigation";
11
5
  export type LayoutAppLink = RailApp;
12
6
 
13
- type LayoutRailProps = {
14
- accent?: string;
15
- /** Set while the installation offers the mobile app. */
16
- appHref?: string;
17
- appsLabel: string;
18
- homeLabel: string;
19
- launchpadApps: AppLaunchpadApp[];
20
- legalLinks: Array<{ label: string; href: string; icon?: string }>;
21
- openAppsLabel: string;
22
- apps: RailApp[];
23
- railSettings: RailSnapshot;
24
- /** `pathname + search` of the request; the island resolves it against the browser origin. */
25
- currentPath: string;
26
- profileAvatarSrc?: string;
27
- profileName: string;
28
- searchHelpApps: GlobalSearchHelpApp[];
29
- searchLinks?: NavigationSearchItem[];
30
- theme: CloudTheme;
31
- };
32
-
33
- const jsonScript = (value: unknown): string => JSON.stringify(value).replace(/</g, "\\u003c");
34
-
35
- export default function LayoutRail(props: LayoutRailProps) {
36
- const profile = { name: props.profileName, theme: props.theme, ...(props.appHref ? { appHref: props.appHref } : {}) };
7
+ /** The rail frame and Home stay server markup; only the navigation is interactive. */
8
+ export default function LayoutRail(props: { currentPath: string }) {
9
+ const locale = useLocale();
10
+ const t = () => platformMessages.resolve([locale()]).t;
37
11
  return (
38
- <>
39
- <AppLaunchpad profile={profile} apps={props.launchpadApps} legalLinks={props.legalLinks} />
40
- <script id="cloud-app-launchpad-data" type="application/json">
41
- {jsonScript({ apps: props.launchpadApps, legalLinks: props.legalLinks, profile })}
42
- </script>
43
- <script id="cloud-rail-data" type="application/json">
44
- {jsonScript({ apps: props.apps, settings: props.railSettings })}
45
- </script>
46
- <aside class="layout-rail hidden w-10 shrink-0 flex-col md:flex">
47
- <div class="layout-rail-logo flex h-[2.875rem] shrink-0 items-center justify-center">
48
- <a href="/" aria-label={props.homeLabel}>
49
- <img src="/branding/logo" alt="Logo" class="h-5 w-5" />
50
- </a>
51
- </div>
52
- <nav class="layout-rail-navigation flex min-h-0 flex-1 flex-col items-center gap-1" aria-label={props.appsLabel}>
53
- <RailApps apps={props.apps} settings={props.railSettings} currentPath={props.currentPath} />
54
- <AppLaunchpad
55
- profile={profile}
56
- apps={props.launchpadApps}
57
- legalLinks={props.legalLinks}
58
- variant="rail"
59
- label={props.openAppsLabel}
60
- />
61
- <div class="mt-auto shrink-0 flex flex-col items-center gap-1">
62
- <GlobalSearchTrigger variant="rail" searchLinks={props.searchLinks} />
63
- <LayoutHelpTrigger variant="rail" registerCommand searchHelpApps={props.searchHelpApps} accent={props.accent} />
64
- <ProfilePreferences
65
- appHref={props.appHref}
66
- avatarSrc={props.profileAvatarSrc}
67
- initialTheme={props.theme}
68
- name={props.profileName}
69
- />
70
- </div>
71
- </nav>
72
- </aside>
73
- </>
12
+ <aside class="layout-rail hidden w-10 shrink-0 flex-col md:flex">
13
+ <div class="layout-rail-logo flex h-[2.875rem] shrink-0 items-center justify-center">
14
+ <a href="/" aria-label={t().home}>
15
+ <img src="/branding/logo" alt="Logo" class="h-5 w-5" />
16
+ </a>
17
+ </div>
18
+ <CloudShell slot="rail" currentPath={props.currentPath} />
19
+ </aside>
74
20
  );
75
21
  }
@@ -2,7 +2,7 @@ import { LocaleProvider } from "@k2b/ui";
2
2
  import type { JSX } from "solid-js/jsx-runtime";
3
3
  import { getLocale } from "../server/locale";
4
4
  import { resolveAppPresentations } from "../shared/app-presentation";
5
- import { readThemeFromCookieHeader } from "../shared/theme";
5
+ import { readThemePreferenceFromCookieHeader } from "../shared/theme";
6
6
  import type { MinimalLayoutContext } from "./layout-context";
7
7
  import MinimalLayoutPreferences from "./MinimalLayoutPreferences.island";
8
8
  import { profilePreferencesMessages } from "./profile-preferences-messages";
@@ -26,9 +26,8 @@ export type MinimalLayoutProps = {
26
26
 
27
27
  export default function MinimalLayout(props: MinimalLayoutProps) {
28
28
  const cookie = props.c.req.raw.headers.get("Cookie") ?? "";
29
- const theme = readThemeFromCookieHeader(cookie);
29
+ const theme = readThemePreferenceFromCookieHeader(cookie);
30
30
  const locale = getLocale(props.c);
31
- props.c.get("page").theme = theme;
32
31
 
33
32
  if (props.preferences === false) {
34
33
  return (
@@ -1,18 +1,12 @@
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
+ import { ThemeCycleButton } from "./ThemeCycleButton";
6
7
 
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 }) {
8
+ /** Language opens a menu; appearance cycles in place and keeps its accessible name below md. */
9
+ export default function MinimalLayoutPreferences(props: { initialTheme: CloudThemePreference }) {
16
10
  const locale = useLocale();
17
11
  const preferences = createPreferenceController(props.initialTheme, locale);
18
12
  const t = preferences.messages;
@@ -43,10 +37,7 @@ 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
+ <ThemeCycleButton preferences={preferences} size="sm" variant="ghost" compact />
50
41
  </div>
51
42
  );
52
43
  }
@@ -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,11 @@
1
1
  import { documentNavigate } from "@k2b/ssr/nav";
2
2
  import { Button, ButtonLink } from "@k2b/ui";
3
- import type { CloudTheme } from "../shared/theme";
3
+ import type { CloudThemePreference } from "../shared/theme";
4
4
  import { createProfileActions } from "./profile-actions";
5
+ import { ThemeCycleButton } from "./ThemeCycleButton";
5
6
 
6
7
  export function MobileProfileActions(props: {
7
- profile: { name: string; theme: CloudTheme; appHref?: string };
8
+ profile: { name: string; theme: CloudThemePreference; appHref?: string };
8
9
  beforeSelect: () => Promise<boolean>;
9
10
  }) {
10
11
  const { preferences, signingOut, signOut } = createProfileActions(props.profile.theme, props.profile.appHref);
@@ -17,6 +18,7 @@ export function MobileProfileActions(props: {
17
18
  };
18
19
  return (
19
20
  <section class="cloud-mobile-profile" aria-label={preferences.messages().menuLabel}>
21
+ <ThemeCycleButton preferences={preferences} variant="ghost" />
20
22
  <ButtonLink href="/me" variant="ghost" onClick={navigate("/me")}>
21
23
  <i class="ti ti-user-circle" aria-hidden="true" />
22
24
  {preferences.messages().profileSettings}
@@ -27,10 +29,6 @@ export function MobileProfileActions(props: {
27
29
  {preferences.messages().app}
28
30
  </ButtonLink>
29
31
  )}
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
32
  <Button
35
33
  variant="ghost"
36
34
  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
1
  import { Avatar, Dropdown } from "@k2b/ui";
2
- import type { CloudTheme } from "../shared/theme";
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,9 +26,9 @@ 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>
29
+ <button type="button" class="menu-item" aria-label={preferences.themeCycleLabel()} onClick={preferences.cycleTheme}>
30
+ <i class={preferences.themeOption(preferences.theme()).icon} aria-hidden="true" />
31
+ <span>{preferences.themeOption(preferences.theme()).label}</span>
32
32
  </button>
33
33
  <button type="button" class="menu-item" onClick={preferences.toggleLanguage}>
34
34
  <i class="ti ti-language" aria-hidden="true" />
@@ -66,6 +66,9 @@ export default function ProfilePreferences(props: ProfilePreferencesProps) {
66
66
  {avatar()}
67
67
  </Dropdown.Trigger>
68
68
  </Dropdown.Root>
69
+ <span class="sr-only" aria-live="polite">
70
+ {preferences.themeAnnouncement()}
71
+ </span>
69
72
  </div>
70
73
  );
71
74
  }
@@ -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 = {
@@ -0,0 +1,166 @@
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: () => importOnDemand(() => import("./LayoutHelp")).then((help) => help?.openLayoutHelpDialog()),
132
+ }),
133
+ ),
134
+ );
135
+ }
136
+ onMount(() => {
137
+ if (props.push) {
138
+ void import("../browser/notifications")
139
+ .then(({ browserNotificationClient }) => browserNotificationClient.refreshExisting())
140
+ .catch((error) => {
141
+ console.warn("[notifications] Failed to refresh the browser endpoint", error);
142
+ });
143
+ }
144
+ if (props.announcements?.entries.length) {
145
+ const announcements = props.announcements;
146
+ void importOnDemand(() => import("./announcements-dialog")).then((loaded) => loaded?.openAnnouncementsDialog(announcements));
147
+ }
148
+ if (props.banners) {
149
+ const dismiss = (event: MouseEvent) => {
150
+ if (!(event.target instanceof Element)) return;
151
+ const button = event.target.closest<HTMLButtonElement>("[data-cloud-announcement-dismiss]");
152
+ if (!button) return;
153
+ const version = Number(button.dataset.cloudAnnouncementDismiss);
154
+ const next = mergeAnnouncementCookieState(parseAnnouncementCookieHeader(document.cookie), { dismissedBannerVersions: [version] });
155
+ document.cookie = `${ANNOUNCEMENTS_COOKIE}=${serializeAnnouncementCookieState(next)}; Path=/; Max-Age=${ANNOUNCEMENTS_COOKIE_MAX_AGE_SECONDS}; SameSite=Lax`;
156
+ const banner = button.closest(".cloud-announcement");
157
+ const list = banner?.parentElement;
158
+ banner?.remove();
159
+ if (list && !list.querySelector(".cloud-announcement")) list.remove();
160
+ };
161
+ document.addEventListener("click", dismiss);
162
+ onCleanup(() => document.removeEventListener("click", dismiss));
163
+ }
164
+ });
165
+ return props.help ? <LayoutHelpDocuments documents={props.help.documents} pageBase={props.help.pageBase} /> : null;
166
+ }
@@ -0,0 +1,46 @@
1
+ import { Button, type ButtonSize, type ButtonVariant } from "@k2b/ui";
2
+ import { type createPreferenceController, THEME_CYCLE } from "./preference-controller";
3
+
4
+ type Props = {
5
+ preferences: ReturnType<typeof createPreferenceController>;
6
+ variant: ButtonVariant;
7
+ size?: ButtonSize;
8
+ /** Shows only the icon below `md`; the accessible name still states the choice. */
9
+ compact?: boolean;
10
+ };
11
+
12
+ /**
13
+ * One button for the appearance: it shows the current choice and each click switches to the next. The label reserves
14
+ * the width of the two other choices, so the button and its neighbors stay in place.
15
+ */
16
+ export function ThemeCycleButton(props: Props) {
17
+ const current = () => props.preferences.themeOption(props.preferences.theme());
18
+ const otherLabels = () =>
19
+ THEME_CYCLE.filter((value) => value !== props.preferences.theme()).map((value) => props.preferences.themeOption(value).label);
20
+
21
+ return (
22
+ <>
23
+ <Button
24
+ type="button"
25
+ class="cloud-theme-cycle"
26
+ variant={props.variant}
27
+ size={props.size}
28
+ aria-label={props.preferences.themeCycleLabel()}
29
+ onClick={props.preferences.cycleTheme}
30
+ >
31
+ <i class={current().icon} aria-hidden="true" />
32
+ <span
33
+ class="cloud-theme-cycle__label"
34
+ data-compact={props.compact ? "true" : undefined}
35
+ data-reserve-a={otherLabels()[0]}
36
+ data-reserve-b={otherLabels()[1]}
37
+ >
38
+ <span>{current().label}</span>
39
+ </span>
40
+ </Button>
41
+ <span class="sr-only" aria-live="polite">
42
+ {props.preferences.themeAnnouncement()}
43
+ </span>
44
+ </>
45
+ );
46
+ }
@@ -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
  }