@k2b/cloud 0.37.0 → 0.39.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (149) hide show
  1. package/package.json +8 -3
  2. package/scripts/build-canvas-workers.ts +109 -29
  3. package/scripts/build.ts +3 -5
  4. package/src/_internal/app-path.ts +25 -0
  5. package/src/_internal/app-presentation.ts +4 -0
  6. package/src/_internal/canvas-worker.ts +5 -3
  7. package/src/_internal/define-app.ts +28 -2
  8. package/src/_internal/help-catalog.ts +123 -4
  9. package/src/_internal/help.ts +27 -6
  10. package/src/_internal/panel-cookie.ts +39 -0
  11. package/src/_internal/registry-validation.ts +21 -26
  12. package/src/_internal/runtime-context.ts +1 -0
  13. package/src/access/PermissionEditor.browser-harness.tsx +57 -38
  14. package/src/access/PermissionEditor.tsx +30 -5
  15. package/src/access/ResourceApiKeys.tsx +5 -1
  16. package/src/account/Avatar.tsx +20 -2
  17. package/src/account/ui.ts +1 -1
  18. package/src/ai/approval-routes.ts +5 -2
  19. package/src/ai/approvals.ts +139 -28
  20. package/src/ai/browser.ts +11 -0
  21. package/src/ai/chart-block.ts +44 -13
  22. package/src/ai/chat/action-sentences.ts +1 -44
  23. package/src/ai/chat/chart-block.tsx +2 -11
  24. package/src/ai/chat/messages.ts +10 -0
  25. package/src/ai/chat/turn-view.tsx +35 -3
  26. package/src/ai/chat/view-block.tsx +7 -2
  27. package/src/ai/chat/web-tools.tsx +23 -6
  28. package/src/ai/chat-task-contracts.ts +28 -3
  29. package/src/ai/chat-task-reasons-migrate.ts +68 -0
  30. package/src/ai/chat-task-reasons.ts +92 -0
  31. package/src/ai/chat-tasks.ts +460 -66
  32. package/src/ai/default-tool-contracts.ts +30 -13
  33. package/src/ai/fetch-file-tool.ts +37 -3
  34. package/src/ai/index.ts +12 -0
  35. package/src/ai/migrate.ts +46 -3
  36. package/src/ai/readable-html.ts +1057 -0
  37. package/src/ai/request-context.ts +7 -1
  38. package/src/ai/routes.ts +27 -1
  39. package/src/ai/runtime.ts +12 -27
  40. package/src/ai/store.ts +5 -3
  41. package/src/ai/timeline.ts +9 -1
  42. package/src/ai/tool-wording.ts +45 -0
  43. package/src/ai/turn-abort.ts +33 -0
  44. package/src/ai/types.ts +12 -1
  45. package/src/api/admin-core-settings.ts +190 -139
  46. package/src/api/admin-outgoing-mail.ts +4 -2
  47. package/src/api/app-approval.ts +9 -2
  48. package/src/api/approvals.ts +1 -1
  49. package/src/api/help.ts +23 -4
  50. package/src/api/index.ts +2 -1
  51. package/src/api/mcp.ts +8 -2
  52. package/src/browser/CloudResourceSearch.tsx +10 -6
  53. package/src/browser/FileChooser.tsx +11 -3
  54. package/src/browser/app-approval.ts +7 -2
  55. package/src/browser/choose-files.browser-harness.tsx +4 -1
  56. package/src/browser/composer-catalog.ts +103 -0
  57. package/src/browser/composer-insert-messages.ts +31 -0
  58. package/src/browser/composer-insert.ts +150 -0
  59. package/src/browser/file-chooser-messages.ts +4 -0
  60. package/src/browser/file-providers.ts +14 -1
  61. package/src/browser/panel-messages.ts +24 -0
  62. package/src/browser/panel-slot.ts +354 -0
  63. package/src/browser/panel.ts +28 -0
  64. package/src/browser/provider-folder.tsx +58 -5
  65. package/src/browser/resource-picker.tsx +17 -2
  66. package/src/cli/admin/workflows.ts +1 -0
  67. package/src/config/env.ts +14 -0
  68. package/src/contracts/app-approval.ts +49 -9
  69. package/src/contracts/app.ts +14 -0
  70. package/src/contracts/file-provider.ts +48 -1
  71. package/src/contracts/outgoing-mail.ts +8 -1
  72. package/src/contracts/registry.ts +3 -1
  73. package/src/contracts/shared.ts +2 -0
  74. package/src/server/help.ts +136 -10
  75. package/src/server/services/access.ts +5 -1
  76. package/src/server/services/geo.ts +21 -4
  77. package/src/services/accounts/posix.ts +2 -1
  78. package/src/services/allowed-network-fetch.ts +189 -0
  79. package/src/services/app-approval.ts +158 -38
  80. package/src/services/approvals/device.ts +2 -0
  81. package/src/services/help/index.ts +46 -5
  82. package/src/services/help/store.ts +26 -5
  83. package/src/services/help/types.ts +10 -2
  84. package/src/services/index.ts +22 -3
  85. package/src/services/notifications/browser.ts +16 -5
  86. package/src/services/outgoing-mail/dispatcher.ts +7 -2
  87. package/src/services/outgoing-mail/resolve-host.ts +39 -0
  88. package/src/services/outgoing-mail/test-send.ts +18 -3
  89. package/src/services/outgoing-mail/transport.ts +24 -13
  90. package/src/services/pdf/image-protocol.ts +135 -0
  91. package/src/services/pdf/image-variants.ts +124 -0
  92. package/src/services/pdf/image-worker.ts +31 -0
  93. package/src/services/pdf/image.ts +109 -0
  94. package/src/services/pdf/index.ts +9 -0
  95. package/src/services/settings/api.ts +11 -6
  96. package/src/services/settings/app.ts +5 -4
  97. package/src/services/settings/core-settings.ts +4 -3
  98. package/src/services/settings/index.ts +67 -43
  99. package/src/services/settings/namespace.ts +2 -1
  100. package/src/services/settings/store.ts +83 -11
  101. package/src/shared/ai-platform-prompt.ts +1 -1
  102. package/src/shared/app-presentation.ts +1 -0
  103. package/src/shared/help.ts +41 -0
  104. package/src/shared/index.ts +9 -1
  105. package/src/shared/markdown/extensions/guided-help.ts +208 -64
  106. package/src/shared/markdown/formula.ts +8 -8
  107. package/src/shared/markdown/index.ts +72 -11
  108. package/src/shared/redirect.ts +12 -0
  109. package/src/ssr/CloudShell.island.tsx +86 -9
  110. package/src/ssr/Layout.tsx +62 -11
  111. package/src/ssr/LayoutHeader.tsx +15 -9
  112. package/src/ssr/LayoutHelp.browser-harness.tsx +130 -27
  113. package/src/ssr/LayoutHelp.tsx +569 -410
  114. package/src/ssr/LayoutRail.tsx +2 -2
  115. package/src/ssr/LayoutTabBar.tsx +29 -0
  116. package/src/ssr/PanelLayout.tsx +27 -0
  117. package/src/ssr/PanelRuntime.island.tsx +87 -0
  118. package/src/ssr/PanelSlot.tsx +108 -0
  119. package/src/ssr/PanelTrigger.tsx +49 -0
  120. package/src/ssr/ShellRuntime.island.tsx +72 -25
  121. package/src/ssr/app-appearance.ts +3 -2
  122. package/src/ssr/app-color.ts +100 -0
  123. package/src/ssr/app-launchpad.tsx +18 -1
  124. package/src/ssr/app-navigation.ts +15 -0
  125. package/src/ssr/compact-shell.baseline.json +5270 -0
  126. package/src/ssr/compact-shell.browser-harness.ts +431 -0
  127. package/src/ssr/help-messages.ts +60 -38
  128. package/src/ssr/help.ts +3 -0
  129. package/src/ssr/index.ts +3 -0
  130. package/src/ssr/layout-help-article.ts +41 -0
  131. package/src/ssr/layout-help-navigation.ts +0 -12
  132. package/src/ssr/layout-help-registry.tsx +4 -11
  133. package/src/ssr/layout-help-search.ts +9 -12
  134. package/src/ssr/layout-help-start.ts +68 -0
  135. package/src/ssr/panel-slot.browser-harness.tsx +110 -0
  136. package/src/ssr/platform-messages.ts +34 -0
  137. package/src/ssr/rail-navigation.ts +44 -0
  138. package/src/ssr/shell-data.tsx +11 -0
  139. package/src/ssr/shell-shortcuts.ts +19 -0
  140. package/src/styles/effects.css +689 -185
  141. package/src/styles/global.css +269 -55
  142. package/src/styles/panel.css +373 -0
  143. package/src/styles/resource-search.css +25 -0
  144. package/src/styles/tokens.css +23 -6
  145. package/src/styles/utilities-navigation.css +5 -21
  146. package/src/workflows/store/events.ts +24 -0
  147. package/src/workflows/store/index.ts +8 -0
  148. package/src/workflows/store/observability.ts +6 -1
  149. package/src/workflows/store/retention.ts +157 -0
@@ -5,7 +5,7 @@ import type { RailApp } from "./rail-navigation";
5
5
  export type LayoutAppLink = RailApp;
6
6
 
7
7
  /** The rail frame and Home stay server markup; only the navigation is interactive. */
8
- export default function LayoutRail(props: { currentPath: string }) {
8
+ export default function LayoutRail(props: { currentPath: string; panelOpen?: boolean }) {
9
9
  const locale = useLocale();
10
10
  const t = () => platformMessages.resolve([locale()]).t;
11
11
  return (
@@ -15,7 +15,7 @@ export default function LayoutRail(props: { currentPath: string }) {
15
15
  <img src="/branding/logo" alt="Logo" class="h-5 w-5" />
16
16
  </a>
17
17
  </div>
18
- <CloudShell slot="rail" currentPath={props.currentPath} />
18
+ <CloudShell slot="rail" currentPath={props.currentPath} panelOpen={props.panelOpen} />
19
19
  </aside>
20
20
  );
21
21
  }
@@ -0,0 +1,29 @@
1
+ import { TabBar, useLocale } from "@k2b/ui";
2
+ import { platformMessages } from "./platform-messages";
3
+ import { phoneBarItems, projectRailNavigation } from "./rail-navigation";
4
+ import { useShellData } from "./shell-data";
5
+
6
+ /**
7
+ * The signed-in phone's bottom bar: Home and the person's first apps in their rail order, floating on the app
8
+ * gradient above the content card. It is server markup, so it stands in its final place before any island mounts and
9
+ * a link focused early keeps its focus. The rail's island takes over only when the bar's content changes in the
10
+ * browser: after the person saves their rail preferences, or when the URL changes in the page. The
11
+ * place of the app's primary action beside it stays free. From 768 px the rail takes over and the bar is hidden.
12
+ */
13
+ export default function LayoutTabBar(props: { currentPath: string }) {
14
+ const data = useShellData();
15
+ const locale = useLocale();
16
+ if (!data?.rail) return null;
17
+ const t = platformMessages.resolve([locale()]).t;
18
+ const items = phoneBarItems(projectRailNavigation(data.apps, data.rail, locale()), props.currentPath, {
19
+ label: t.home,
20
+ path: data.home ?? "/",
21
+ });
22
+ return (
23
+ <div class="cloud-tab-bar">
24
+ <div class="cloud-tab-bar__row">
25
+ <TabBar variant="floating" label={t.apps} items={items} />
26
+ </div>
27
+ </div>
28
+ );
29
+ }
@@ -0,0 +1,27 @@
1
+ import { LocaleProvider } from "@k2b/ui";
2
+ import type { JSX } from "solid-js";
3
+ import { canonicalRoute } from "../_internal/app-path";
4
+ import { getLocale } from "../server/locale";
5
+ import { resolveRuntimeRoute } from "./app-navigation";
6
+ import type { MinimalLayoutContext } from "./layout-context";
7
+ import PanelRuntime from "./PanelRuntime.island";
8
+ import { getRuntimeContext } from "./runtime";
9
+
10
+ export type PanelLayoutProps = { c: MinimalLayoutContext; title: string; children: JSX.Element };
11
+
12
+ /** A provider page embedded in the shell's same-origin panel. */
13
+ export default function PanelLayout(props: PanelLayoutProps) {
14
+ const runtime = getRuntimeContext(props.c);
15
+ // The provider owns this path by the canonical prefixes its panel declaration was checked against.
16
+ const apps = runtime.apps.map((entry) => ({ ...entry, routes: entry.routes.map(canonicalRoute) }));
17
+ const app = resolveRuntimeRoute(apps, new URL(props.c.req.raw.url).pathname)?.app;
18
+ props.c.get("page").title = props.title;
19
+ return (
20
+ <LocaleProvider locale={getLocale(props.c)}>
21
+ <div class="cloud-panel-page" data-cloud-panel-page="" data-panel-provider={app?.id}>
22
+ <PanelRuntime provider={app?.id} scope={app?.panel ? new URL(app.panel.href, props.c.req.raw.url).pathname : undefined} />
23
+ {props.children}
24
+ </div>
25
+ </LocaleProvider>
26
+ );
27
+ }
@@ -0,0 +1,87 @@
1
+ import { installAppWorkspaceController, installInputModeController, keyboardPlatform } from "@k2b/ui";
2
+ import { onCleanup, onMount } from "solid-js";
3
+ import { syncTimezoneCookie } from "./timezone-cookie";
4
+
5
+ export default function PanelRuntime(props: { provider?: string; scope?: string }) {
6
+ onMount(() => {
7
+ syncTimezoneCookie(true);
8
+ const disposeInput = installInputModeController();
9
+ // The SSR body hands workspace controller ownership to the page runtime, as Layout's ShellRuntime does. A panel
10
+ // page keeps no workspace layout cookie: the app's own layout must not follow the reader into a narrow panel.
11
+ const disposeWorkspace = installAppWorkspaceController();
12
+ const deferred = new Set<ReturnType<typeof setTimeout>>();
13
+ const embedded = window.parent !== window;
14
+ let disposeTheme = () => {};
15
+ let resumeTheme = () => {};
16
+ // A page kept in the back/forward cache keeps following the host; only a removed iframe lets go.
17
+ const pageHide = (event: PageTransitionEvent) => {
18
+ if (!event.persisted) disposeTheme();
19
+ };
20
+ const pageShow = (event: PageTransitionEvent) => {
21
+ if (event.persisted) resumeTheme();
22
+ };
23
+ const close = () => window.parent.postMessage({ type: "cloud.panel.close" }, location.origin);
24
+ const click = (event: MouseEvent) => {
25
+ if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) return;
26
+ const anchor = event.target instanceof Element ? event.target.closest<HTMLAnchorElement>("a[href]") : null;
27
+ if (!anchor || (anchor.target && anchor.target !== "_self") || anchor.hasAttribute("download")) return;
28
+ const url = new URL(anchor.getAttribute("href")!, document.baseURI);
29
+ if (url.origin !== location.origin) return;
30
+ const scope = props.scope?.replace(/\/$/, "");
31
+ if (scope !== undefined && (url.pathname === scope || url.pathname.startsWith(`${scope}/`))) return;
32
+ event.preventDefault();
33
+ window.parent.location.assign(url.href);
34
+ };
35
+ const key = (event: KeyboardEvent) => {
36
+ if (event.isComposing || event.defaultPrevented || event.altKey || event.shiftKey) return;
37
+ const apple = keyboardPlatform() === "apple";
38
+ if (event.key.toLowerCase() === "j" && event.metaKey === apple && event.ctrlKey === !apple) {
39
+ event.preventDefault();
40
+ if (!event.repeat) close();
41
+ } else if (event.key === "Escape" && !event.metaKey && !event.ctrlKey) {
42
+ // An open modal dialog takes Escape through its cancel event, without preventing this keydown.
43
+ if (document.querySelector("dialog[open], [aria-modal='true']")) return;
44
+ const timer = setTimeout(() => {
45
+ deferred.delete(timer);
46
+ if (!event.defaultPrevented) close();
47
+ }, 0);
48
+ deferred.add(timer);
49
+ }
50
+ };
51
+ if (embedded) {
52
+ try {
53
+ const parent = window.parent;
54
+ const parentRoot = parent.document.documentElement;
55
+ const syncTheme = () => {
56
+ for (const theme of ["light", "dark"]) {
57
+ document.documentElement.classList.toggle(theme, parentRoot.classList.contains(theme));
58
+ }
59
+ };
60
+ parent.addEventListener("cloud:theme-preference", syncTheme);
61
+ disposeTheme = () => parent.removeEventListener("cloud:theme-preference", syncTheme);
62
+ resumeTheme = syncTheme;
63
+ syncTheme();
64
+ } catch {
65
+ // A cross-origin parent cannot share its document or theme events.
66
+ }
67
+ window.addEventListener("pagehide", pageHide);
68
+ window.addEventListener("pageshow", pageShow);
69
+ // The shell reads this mark when its controller arrives after the message.
70
+ if (props.provider) document.documentElement.dataset.cloudPanelReady = props.provider;
71
+ window.parent.postMessage({ type: "cloud.panel.ready", v: 1, provider: props.provider }, location.origin);
72
+ window.addEventListener("keydown", key);
73
+ window.addEventListener("click", click);
74
+ }
75
+ onCleanup(() => {
76
+ disposeInput();
77
+ disposeWorkspace();
78
+ disposeTheme();
79
+ window.removeEventListener("pagehide", pageHide);
80
+ window.removeEventListener("pageshow", pageShow);
81
+ window.removeEventListener("keydown", key);
82
+ window.removeEventListener("click", click);
83
+ for (const timer of deferred) clearTimeout(timer);
84
+ });
85
+ });
86
+ return null;
87
+ }
@@ -0,0 +1,108 @@
1
+ import { Button, IconButton, type KeyboardPlatform, Placeholder, ResizeSeparator, useLocale } from "@k2b/ui";
2
+ import { PANEL_MAX_WIDTH_REM, PANEL_MIN_WIDTH_REM, type PanelCookieState } from "../_internal/panel-cookie";
3
+ import { platformMessages } from "./platform-messages";
4
+ import type { ShellData } from "./shell-data";
5
+
6
+ /** Server markup only. The shell loads its controller on demand. */
7
+ export default function PanelSlot(props: { panel: NonNullable<ShellData["panel"]>; state: PanelCookieState; platform?: KeyboardPlatform }) {
8
+ const locale = useLocale();
9
+ const t = platformMessages.resolve([locale()]).t;
10
+ const provider = props.panel.providers.find((provider) => provider.id === props.panel.provider) ?? props.panel.providers[0];
11
+ if (!provider) return null;
12
+ const tabs = props.panel.providers.length > 1;
13
+ return (
14
+ <aside
15
+ class="cloud-panel"
16
+ id="cloud-panel"
17
+ data-cloud-panel=""
18
+ aria-label={t.panel}
19
+ data-state={props.state.open ? "loading" : "closed"}
20
+ data-provider={provider.id}
21
+ >
22
+ <ResizeSeparator
23
+ class="cloud-panel__resize"
24
+ label={t.resizePanel}
25
+ orientation="vertical"
26
+ controls="cloud-panel"
27
+ min={PANEL_MIN_WIDTH_REM}
28
+ max={PANEL_MAX_WIDTH_REM}
29
+ value={props.state.widthRem ?? 26}
30
+ grow="start"
31
+ onResize={() => {}}
32
+ />
33
+ <div class="cloud-panel__card">
34
+ <button type="button" class="cloud-panel__grabber" data-cloud-panel-grow="" aria-label={t.enlargePanel}>
35
+ <span aria-hidden="true" />
36
+ </button>
37
+ <div class="cloud-panel__head">
38
+ {tabs ? (
39
+ <div class="cloud-panel__tabs" role="tablist" aria-label={t.panels}>
40
+ {props.panel.providers.map((item) => (
41
+ <button
42
+ type="button"
43
+ role="tab"
44
+ class="cloud-panel__tab"
45
+ data-cloud-panel-provider={item.id}
46
+ aria-selected={item.id === provider.id}
47
+ aria-controls="cloud-panel-body"
48
+ tabindex={item.id === provider.id ? 0 : -1}
49
+ >
50
+ <i class={item.icon} aria-hidden="true" />
51
+ <span class="cloud-panel__tab-label">{item.label}</span>
52
+ </button>
53
+ ))}
54
+ </div>
55
+ ) : (
56
+ <h2 class="cloud-panel__title">
57
+ <i class={provider.icon} aria-hidden="true" />
58
+ <span>{provider.label}</span>
59
+ </h2>
60
+ )}
61
+ <IconButton
62
+ label={t.closePanel}
63
+ title={`${t.closePanel} (${props.platform === "apple" ? "⌘J" : "Ctrl+J"})`}
64
+ data-cloud-panel-close=""
65
+ >
66
+ <i class="ti ti-x" aria-hidden="true" />
67
+ </IconButton>
68
+ </div>
69
+ <div class="cloud-panel__body" id="cloud-panel-body" role={tabs ? "tabpanel" : undefined}>
70
+ <div class="cloud-panel__skeleton" aria-hidden="true">
71
+ <span />
72
+ <span />
73
+ <span />
74
+ </div>
75
+ {/* Lazy: below 768 px the open cookie leaves the slot hidden, and a hidden iframe must not load its provider. */}
76
+ {props.state.open && (
77
+ <iframe
78
+ class="cloud-panel__frame"
79
+ src={provider.href}
80
+ title={provider.label}
81
+ loading="lazy"
82
+ data-cloud-panel-frame={provider.id}
83
+ />
84
+ )}
85
+ <div class="cloud-panel__fallback">
86
+ <Placeholder
87
+ state="error"
88
+ variant="panel"
89
+ title={t.panelLoadFailed}
90
+ description={t.panelNotResponding({ label: provider.label })}
91
+ action={<Button data-cloud-panel-retry="">{t.retry}</Button>}
92
+ />
93
+ </div>
94
+ <div class="cloud-panel__shield" aria-hidden="true" />
95
+ <div class="cloud-panel__closing" aria-hidden="true">
96
+ <span>
97
+ <i class="ti ti-x" />
98
+ {t.releaseClosesPanel}
99
+ </span>
100
+ </div>
101
+ </div>
102
+ </div>
103
+ <div role="status" class="k2b-sr-only" data-cloud-panel-status="">
104
+ {props.state.open ? t.panelLoading({ label: provider.label }) : ""}
105
+ </div>
106
+ </aside>
107
+ );
108
+ }
@@ -0,0 +1,49 @@
1
+ import { IconButton, Tooltip, useLocale } from "@k2b/ui";
2
+ import { isServer } from "solid-js/web";
3
+ import type { PanelRequest } from "../browser/panel";
4
+ import { platformMessages } from "./platform-messages";
5
+ import { useShellData } from "./shell-data";
6
+
7
+ export default function PanelTrigger(props: { variant: "rail" | "header"; open?: boolean }) {
8
+ const locale = useLocale();
9
+ const data = useShellData();
10
+ const t = () => platformMessages.resolve([locale()]).t;
11
+ const title = () => t().panelShortcut({ mod: data?.platform === "apple" ? "⌘" : "Ctrl+" });
12
+ // The lazy controller may have changed state before this island mounts.
13
+ const open = () =>
14
+ isServer
15
+ ? !!props.open
16
+ : document
17
+ .querySelector(".cloud-app-canvas")
18
+ ?.getAttribute(window.matchMedia("(min-width: 768px)").matches ? "data-panel" : "data-panel-sheet") === "open";
19
+ const toggle = () => window.dispatchEvent(new CustomEvent<PanelRequest>("cloud.panel.request", { detail: { action: "toggle" } }));
20
+ if (props.variant === "rail")
21
+ return (
22
+ <Tooltip.Trigger
23
+ type="button"
24
+ class="rail-item"
25
+ aria-label={t().openPanel}
26
+ aria-controls="cloud-panel"
27
+ aria-expanded={open()}
28
+ data-cloud-panel-toggle=""
29
+ content={title()}
30
+ placement="right"
31
+ delay={0}
32
+ onClick={toggle}
33
+ >
34
+ <i class="ti ti-layout-sidebar-right text-xl" aria-hidden="true" />
35
+ </Tooltip.Trigger>
36
+ );
37
+ return (
38
+ <IconButton
39
+ label={t().openPanel}
40
+ title={title()}
41
+ aria-controls="cloud-panel"
42
+ aria-expanded={open()}
43
+ data-cloud-panel-toggle=""
44
+ onClick={toggle}
45
+ >
46
+ <i class="ti ti-layout-sidebar-right text-base" aria-hidden="true" />
47
+ </IconButton>
48
+ );
49
+ }
@@ -2,7 +2,9 @@ import type { HelpDocumentManifest } from "@k2b/cloud/shared";
2
2
  import {
3
3
  followPageTrail,
4
4
  installAppWorkspaceController,
5
+ installInputModeController,
5
6
  installPageTitleController,
7
+ keyboardPlatform,
6
8
  serializeAppWorkspaceLayoutState,
7
9
  useLocale,
8
10
  } from "@k2b/ui";
@@ -11,6 +13,7 @@ import { appWorkspaceCookieName, readAppWorkspaceLayoutCookie } from "../_intern
11
13
  import { registerContextAwareCommand } from "../browser/command-bridge";
12
14
  import { attachCommandShortcuts } from "../browser/command-shortcuts";
13
15
  import { navigationSearchItems } from "../browser/navigation-search";
16
+ import type { PanelRequest } from "../browser/panel";
14
17
  import { importOnDemand } from "../browser/reload";
15
18
  import { registerGlobalSearchHost } from "../browser/search-bridge";
16
19
  import { attachShellFocusControls, readShellFocus, SHELL_FOCUS_CHANGE_EVENT, toggleShellFocus } from "../browser/shell-focus";
@@ -27,9 +30,12 @@ import { LayoutHelpDocuments } from "./layout-help-registry";
27
30
  import { installPageTrailRuntime } from "./page-trail-runtime";
28
31
  import { platformMessages } from "./platform-messages";
29
32
  import { readShellData } from "./shell-data";
33
+ import { shellShortcuts } from "./shell-shortcuts";
30
34
  import { syncTimezoneCookie } from "./timezone-cookie";
31
35
 
32
36
  export type ShellRuntimeProps = {
37
+ /** This page has an authorized panel slot. */
38
+ panel?: boolean;
33
39
  /** Layout pages outside focus mode: keep the current app's workspace layout in its cookie. */
34
40
  workspace?: { appId?: string };
35
41
  /** Layout pages: page titles, the way up (Alt+Up) and its per-tab memory. */
@@ -66,6 +72,56 @@ export default function ShellRuntime(props: ShellRuntimeProps) {
66
72
  const locale = useLocale();
67
73
  const t = () => platformMessages.resolve([locale()]).t;
68
74
  onMount(() => syncTimezoneCookie(true));
75
+ onMount(() => onCleanup(installInputModeController()));
76
+
77
+ const togglePanel = () => {
78
+ window.dispatchEvent(new CustomEvent<PanelRequest>("cloud.panel.request", { detail: { action: "toggle" } }));
79
+ };
80
+ if (props.panel) {
81
+ onMount(() => {
82
+ let stopped = false;
83
+ let dispose: (() => void) | undefined;
84
+ const load = async (request?: PanelRequest) => {
85
+ const panel = await importOnDemand(() => import("../browser/panel-slot"));
86
+ if (!panel || stopped) return;
87
+ dispose ??= panel.installPanelSlot();
88
+ if (request) panel.handlePanelRequest(request);
89
+ };
90
+ const request = (event: CustomEvent<PanelRequest>) => {
91
+ const detail = event.detail;
92
+ if (detail.action === "open") {
93
+ if (detail.accepted || !readShellData()?.panel?.providers.some((provider) => provider.id === detail.provider)) return;
94
+ detail.accepted = true;
95
+ }
96
+ void load(event.detail);
97
+ };
98
+ const key = (event: KeyboardEvent) => {
99
+ const apple = keyboardPlatform() === "apple";
100
+ if (
101
+ event.defaultPrevented ||
102
+ event.isComposing ||
103
+ event.repeat ||
104
+ event.altKey ||
105
+ event.shiftKey ||
106
+ event.key.toLowerCase() !== "j" ||
107
+ event.metaKey !== apple ||
108
+ event.ctrlKey !== !apple
109
+ )
110
+ return;
111
+ event.preventDefault();
112
+ togglePanel();
113
+ };
114
+ window.addEventListener("cloud.panel.request", request);
115
+ if (!props.commands) window.addEventListener("keydown", key);
116
+ if (document.querySelector('.cloud-app-canvas[data-panel="open"]')) void load();
117
+ onCleanup(() => {
118
+ stopped = true;
119
+ dispose?.();
120
+ window.removeEventListener("cloud.panel.request", request);
121
+ window.removeEventListener("keydown", key);
122
+ });
123
+ });
124
+ }
69
125
 
70
126
  if (props.workspace) {
71
127
  onMount(() => {
@@ -142,31 +198,22 @@ export default function ShellRuntime(props: ShellRuntimeProps) {
142
198
  ),
143
199
  ),
144
200
  );
145
- createEffect(() =>
146
- onCleanup(
147
- registerContextAwareCommand({
148
- id: "cloud.search",
149
- title: t().openGlobalSearch,
150
- description: t().globalSearchDescription,
151
- icon: "ti ti-search",
152
- shortcut: "mod+k",
153
- action: { search: {} },
154
- }),
155
- ),
156
- );
157
- createEffect(() =>
158
- onCleanup(
159
- registerContextAwareCommand({
160
- id: "cloud.help",
161
- title: t().openShortcutHelp,
162
- description: t().shortcutHelpDescription,
163
- icon: "ti ti-help-circle",
164
- shortcut: "shift+/",
165
- // Returning the load keeps the Command busy until the dialog is open; a second press opens no second dialog.
166
- action: () => importOnDemand(() => import("./LayoutHelp")).then((help) => help?.openLayoutHelpDialog()),
167
- }),
168
- ),
169
- );
201
+ createEffect(() => {
202
+ for (const command of shellShortcuts(t(), { panel: props.panel })) {
203
+ onCleanup(
204
+ registerContextAwareCommand({
205
+ ...command,
206
+ action:
207
+ command.id === "cloud.panel"
208
+ ? togglePanel
209
+ : command.id === "cloud.search"
210
+ ? { search: {} }
211
+ : // Returning the load keeps the Command busy until the dialog is open; a second press opens no second dialog.
212
+ () => importOnDemand(() => import("./LayoutHelp")).then((help) => help?.openLayoutHelpDialog()),
213
+ }),
214
+ );
215
+ }
216
+ });
170
217
  }
171
218
  onMount(() => {
172
219
  if (props.push) {
@@ -1,4 +1,5 @@
1
1
  import type { AppAppearance } from "../contracts/app";
2
+ import { appColorStyle } from "./app-color";
2
3
  import { resolveRuntimeRoute } from "./app-navigation";
3
4
  import { helpPageAppId } from "./layout-help-url";
4
5
  import type { RuntimeContext } from "./runtime";
@@ -15,11 +16,11 @@ export const appAppearanceStyle = (appearance: AppAppearance | undefined): strin
15
16
  const angle = Number.isFinite(appearance.background?.angle) ? Math.max(0, Math.min(360, appearance.background?.angle ?? 135)) : 135;
16
17
  const strength = clampStrength(appearance.background?.strength, 20);
17
18
  const darkStrength = Math.round(strength / 2);
18
- return `--app-accent:${appearance.accent};--app-canvas-from:${from};--app-canvas-via:${via};--app-canvas-to:${to};--app-canvas-angle:${angle}deg;--app-canvas-strength:${strength}%;--app-canvas-dark-strength:${darkStrength}%`;
19
+ return `--app-accent:${appearance.accent};${appColorStyle(appearance.accent)};--app-canvas-from:${from};--app-canvas-via:${via};--app-canvas-to:${to};--app-canvas-angle:${angle}deg;--app-canvas-strength:${strength}%;--app-canvas-dark-strength:${darkStrength}%`;
19
20
  };
20
21
 
21
22
  export const appAccentStyle = (accent: string | undefined): string | undefined =>
22
- isHexColor(accent) ? `--app-accent:${accent}` : undefined;
23
+ isHexColor(accent) ? `--app-accent:${accent};${appColorStyle(accent)}` : undefined;
23
24
 
24
25
  /** Core's full-page Help of an app belongs to that app. */
25
26
  export const resolveCurrentApp = (apps: RuntimeContext["apps"], pathname: string) => {
@@ -0,0 +1,100 @@
1
+ type HexColor = `#${string}`;
2
+
3
+ const minimumContrast = 4.5;
4
+ const fillShares = {
5
+ light: { selected: 0.07, selectedNav: 0.1 },
6
+ dark: { selected: 0.14, selectedNav: 0.17 },
7
+ } as const;
8
+
9
+ /**
10
+ * Opaque theme backgrounds checked for readable app ink; a test pins them to
11
+ * tokens.css and @k2b/ui. `sidebar` is the workspace sidebar
12
+ * (`--k2b-surface-muted`) and `sidebarHover` its row hover over it; `subtle`,
13
+ * `muted`, `hover`, and `platformSelected` are the `--ui-surface-subtle`,
14
+ * `--ui-surface-muted`, `--ui-hover`, and blue `--ui-selected` fills app ink
15
+ * also sits on.
16
+ */
17
+ export const appColorSurfaces = {
18
+ light: {
19
+ canvas: "#fafafa",
20
+ surface: "#ffffff",
21
+ sidebar: "#f4f4f5",
22
+ sidebarHover: "#e8e8e9",
23
+ subtle: "#f8f8f8",
24
+ muted: "#f3f4f3",
25
+ raised: "#ffffff",
26
+ hover: "#f0f1f0",
27
+ platformSelected: "#f0f6ff",
28
+ },
29
+ dark: {
30
+ canvas: "#090d12",
31
+ surface: "#11151b",
32
+ sidebar: "#20242a",
33
+ sidebarHover: "#2b2f34",
34
+ subtle: "#161a20",
35
+ muted: "#20242a",
36
+ raised: "#181d24",
37
+ hover: "#252c35",
38
+ platformSelected: "#192636",
39
+ },
40
+ } as const;
41
+
42
+ const channels = (hex: HexColor) =>
43
+ [Number.parseInt(hex.slice(1, 3), 16), Number.parseInt(hex.slice(3, 5), 16), Number.parseInt(hex.slice(5, 7), 16)] as const;
44
+
45
+ /** WCAG 2.x relative luminance from a six-digit sRGB hex color. */
46
+ export const relativeLuminance = (hex: HexColor): number => {
47
+ const linearize = (value: number) => {
48
+ const c = value / 255;
49
+ return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
50
+ };
51
+ const [r, g, b] = channels(hex);
52
+ return 0.2126 * linearize(r) + 0.7152 * linearize(g) + 0.0722 * linearize(b);
53
+ };
54
+
55
+ /** WCAG 2.x contrast ratio between two opaque hex colors. */
56
+ export const contrastRatio = (a: HexColor, b: HexColor): number => {
57
+ const first = relativeLuminance(a);
58
+ const second = relativeLuminance(b);
59
+ return (Math.max(first, second) + 0.05) / (Math.min(first, second) + 0.05);
60
+ };
61
+
62
+ /** Rounded sRGB channel mix, with share indicating the fraction of a. */
63
+ export const mixHex = (a: HexColor, b: HexColor, share: number): HexColor => {
64
+ const second = channels(b);
65
+ const fraction = Math.max(0, Math.min(1, share));
66
+ return `#${channels(a)
67
+ .map((value, index) =>
68
+ Math.round(value * fraction + second[index]! * (1 - fraction))
69
+ .toString(16)
70
+ .padStart(2, "0"),
71
+ )
72
+ .join("")}`;
73
+ };
74
+
75
+ /** Derive selection fills and the nearest AA-readable app ink in each theme. */
76
+ export const appColors = (accent: HexColor) => {
77
+ const forTheme = (theme: keyof typeof appColorSurfaces) => {
78
+ const surfaces = appColorSurfaces[theme];
79
+ const selected = mixHex(accent, surfaces.surface, fillShares[theme].selected);
80
+ const selectedNav = mixHex(accent, surfaces.sidebar, fillShares[theme].selectedNav);
81
+ const backgrounds = [...Object.values(surfaces), selected, selectedNav];
82
+ const toward = theme === "light" ? "#000000" : "#ffffff";
83
+ let ink: HexColor = toward;
84
+ for (let i = 100; i >= 0; i--) {
85
+ const candidate = mixHex(accent, toward, i / 100);
86
+ if (backgrounds.every((background) => contrastRatio(candidate, background) >= minimumContrast)) {
87
+ ink = candidate;
88
+ break;
89
+ }
90
+ }
91
+ return { ink, selected, selectedNav };
92
+ };
93
+ return { light: forTheme("light"), dark: forTheme("dark") };
94
+ };
95
+
96
+ /** Plain hex declarations for server-rendered app appearance and accent scopes. */
97
+ export const appColorStyle = (accent: HexColor): string => {
98
+ const { light, dark } = appColors(accent);
99
+ return `--app-ink-light:${light.ink};--app-ink-dark:${dark.ink};--app-selected-light:${light.selected};--app-selected-dark:${dark.selected};--app-selected-nav-light:${light.selectedNav};--app-selected-nav-dark:${dark.selectedNav}`;
100
+ };
@@ -66,7 +66,7 @@ export function AppLaunchpadProvider(props: AppLaunchpadContext) {
66
66
  return <span class="hidden" data-cloud-app-launchpad-provider />;
67
67
  }
68
68
 
69
- export function AppLaunchpadButton(props: AppLaunchpadContext & { variant: "rail" | "header" | "menu"; label?: string }) {
69
+ export function AppLaunchpadButton(props: AppLaunchpadContext & { variant: "rail" | "header" | "title" | "menu"; label?: string }) {
70
70
  const locale = useLocale();
71
71
  const t = () => platformMessages.resolve([locale()]).t;
72
72
  const action = createBusyAction();
@@ -98,6 +98,23 @@ export function AppLaunchpadButton(props: AppLaunchpadContext & { variant: "rail
98
98
  );
99
99
  }
100
100
 
101
+ // The page title as the app menu's trigger on phones; global.css shows it only when the page has app navigation.
102
+ if (props.variant === "title") {
103
+ return (
104
+ <button
105
+ type="button"
106
+ class="layout-header-title-menu"
107
+ aria-haspopup="dialog"
108
+ onClick={open}
109
+ aria-busy={action.busy() ? "true" : undefined}
110
+ >
111
+ <span class="truncate font-semibold">{props.label}</span>
112
+ <span class="sr-only">{`, ${t().appMenu}`}</span>
113
+ <i class={action.busy() ? "ti ti-loader-2 animate-spin" : "ti ti-chevron-down"} aria-hidden="true" />
114
+ </button>
115
+ );
116
+ }
117
+
101
118
  if (props.variant === "header") {
102
119
  return (
103
120
  <IconButton label={props.label ?? t().openApps} onClick={open} aria-busy={action.busy() ? "true" : undefined}>
@@ -1,3 +1,4 @@
1
+ import { isOwnAppPath } from "../_internal/app-path";
1
2
  import { hasAnyAppRole } from "../_internal/app-roles";
2
3
  import type { RuntimeAppMeta } from "../contracts/app";
3
4
  import { isPwaPartId } from "../contracts/pwa-paths";
@@ -52,3 +53,17 @@ export const visiblePwaParts = (apps: readonly RuntimeAppMeta[], user: User | un
52
53
  .map((app) => resolveAppPresentation(app, locale))
53
54
  .sort((a, b) => a.name.localeCompare(b.name, locale) || a.id.localeCompare(b.id));
54
55
  };
56
+
57
+ export type PanelProvider = { id: string; label: string; icon: string; href: string };
58
+
59
+ /** Authorized presentation only: the panel route still authorizes every request. */
60
+ export const visiblePanelProviders = (apps: readonly RuntimeAppMeta[], user: User | undefined, locale: string): PanelProvider[] => {
61
+ if (!user) return [];
62
+ return apps
63
+ .flatMap((app) => {
64
+ if (!app.panel || !isOwnAppPath(app.panel.href, app.routes) || !hasAnyAppRole(user, app.panel.requiresRoles)) return [];
65
+ const panel = resolveAppPresentation(app, locale).panel;
66
+ return panel ? [{ id: app.id, label: panel.label, icon: panel.icon, href: panel.href }] : [];
67
+ })
68
+ .sort((a, b) => a.label.localeCompare(b.label, locale) || a.id.localeCompare(b.id));
69
+ };