@k2b/cloud 0.38.0 → 0.39.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/package.json +6 -3
  2. package/scripts/build-canvas-workers.ts +109 -29
  3. package/scripts/build.ts +3 -5
  4. package/src/_internal/app-path.ts +25 -0
  5. package/src/_internal/app-presentation.ts +4 -0
  6. package/src/_internal/canvas-worker.ts +5 -3
  7. package/src/_internal/define-app.ts +28 -2
  8. package/src/_internal/panel-cookie.ts +39 -0
  9. package/src/_internal/registry-validation.ts +21 -26
  10. package/src/_internal/runtime-context.ts +1 -0
  11. package/src/access/PermissionEditor.browser-harness.tsx +57 -38
  12. package/src/access/PermissionEditor.tsx +30 -5
  13. package/src/access/ResourceApiKeys.tsx +5 -1
  14. package/src/ai/approval-routes.ts +5 -2
  15. package/src/ai/approvals.ts +139 -28
  16. package/src/ai/browser.ts +10 -0
  17. package/src/ai/chat/action-sentences.ts +1 -44
  18. package/src/ai/chat/turn-view.tsx +35 -3
  19. package/src/ai/chat/view-block.tsx +7 -2
  20. package/src/ai/chat-task-contracts.ts +28 -3
  21. package/src/ai/chat-task-reasons-migrate.ts +68 -0
  22. package/src/ai/chat-task-reasons.ts +92 -0
  23. package/src/ai/chat-tasks.ts +396 -66
  24. package/src/ai/index.ts +12 -0
  25. package/src/ai/migrate.ts +28 -2
  26. package/src/ai/request-context.ts +7 -1
  27. package/src/ai/routes.ts +27 -1
  28. package/src/ai/runtime.ts +12 -27
  29. package/src/ai/store.ts +5 -3
  30. package/src/ai/timeline.ts +9 -1
  31. package/src/ai/tool-wording.ts +45 -0
  32. package/src/ai/turn-abort.ts +33 -0
  33. package/src/ai/types.ts +12 -1
  34. package/src/api/admin-core-settings.ts +190 -139
  35. package/src/api/admin-outgoing-mail.ts +4 -2
  36. package/src/api/app-approval.ts +9 -2
  37. package/src/api/approvals.ts +1 -1
  38. package/src/api/index.ts +2 -1
  39. package/src/browser/CloudResourceSearch.tsx +10 -6
  40. package/src/browser/app-approval.ts +7 -2
  41. package/src/browser/composer-catalog.ts +103 -0
  42. package/src/browser/composer-insert-messages.ts +31 -0
  43. package/src/browser/composer-insert.ts +150 -0
  44. package/src/browser/panel-messages.ts +24 -0
  45. package/src/browser/panel-slot.ts +354 -0
  46. package/src/browser/panel.ts +28 -0
  47. package/src/browser/resource-picker.tsx +12 -1
  48. package/src/contracts/app-approval.ts +49 -9
  49. package/src/contracts/app.ts +14 -0
  50. package/src/contracts/registry.ts +3 -1
  51. package/src/contracts/shared.ts +2 -0
  52. package/src/server/services/access.ts +5 -1
  53. package/src/services/accounts/posix.ts +2 -1
  54. package/src/services/app-approval.ts +158 -38
  55. package/src/services/approvals/device.ts +2 -0
  56. package/src/services/index.ts +21 -3
  57. package/src/services/notifications/browser.ts +16 -5
  58. package/src/services/pdf/image-protocol.ts +135 -0
  59. package/src/services/pdf/image-variants.ts +124 -0
  60. package/src/services/pdf/image-worker.ts +31 -0
  61. package/src/services/pdf/image.ts +109 -0
  62. package/src/services/pdf/index.ts +9 -0
  63. package/src/services/settings/api.ts +11 -6
  64. package/src/services/settings/app.ts +5 -4
  65. package/src/services/settings/core-settings.ts +4 -3
  66. package/src/services/settings/index.ts +19 -17
  67. package/src/services/settings/namespace.ts +2 -1
  68. package/src/services/settings/store.ts +83 -11
  69. package/src/shared/ai-platform-prompt.ts +1 -1
  70. package/src/shared/app-presentation.ts +1 -0
  71. package/src/shared/redirect.ts +12 -0
  72. package/src/ssr/CloudShell.island.tsx +86 -9
  73. package/src/ssr/Layout.tsx +42 -9
  74. package/src/ssr/LayoutHeader.tsx +15 -9
  75. package/src/ssr/LayoutHelp.tsx +1 -1
  76. package/src/ssr/LayoutRail.tsx +2 -2
  77. package/src/ssr/LayoutTabBar.tsx +29 -0
  78. package/src/ssr/PanelLayout.tsx +27 -0
  79. package/src/ssr/PanelRuntime.island.tsx +87 -0
  80. package/src/ssr/PanelSlot.tsx +108 -0
  81. package/src/ssr/PanelTrigger.tsx +49 -0
  82. package/src/ssr/ShellRuntime.island.tsx +60 -5
  83. package/src/ssr/app-appearance.ts +3 -2
  84. package/src/ssr/app-color.ts +100 -0
  85. package/src/ssr/app-launchpad.tsx +18 -1
  86. package/src/ssr/app-navigation.ts +15 -0
  87. package/src/ssr/compact-shell.baseline.json +5270 -0
  88. package/src/ssr/compact-shell.browser-harness.ts +431 -0
  89. package/src/ssr/index.ts +3 -0
  90. package/src/ssr/panel-slot.browser-harness.tsx +110 -0
  91. package/src/ssr/platform-messages.ts +30 -0
  92. package/src/ssr/rail-navigation.ts +44 -0
  93. package/src/ssr/shell-data.tsx +4 -0
  94. package/src/ssr/shell-shortcuts.ts +13 -2
  95. package/src/styles/effects.css +3 -2
  96. package/src/styles/global.css +269 -55
  97. package/src/styles/panel.css +373 -0
  98. package/src/styles/tokens.css +23 -6
  99. package/src/styles/utilities-navigation.css +5 -21
@@ -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
+ }
@@ -4,6 +4,7 @@ import {
4
4
  installAppWorkspaceController,
5
5
  installInputModeController,
6
6
  installPageTitleController,
7
+ keyboardPlatform,
7
8
  serializeAppWorkspaceLayoutState,
8
9
  useLocale,
9
10
  } from "@k2b/ui";
@@ -12,6 +13,7 @@ import { appWorkspaceCookieName, readAppWorkspaceLayoutCookie } from "../_intern
12
13
  import { registerContextAwareCommand } from "../browser/command-bridge";
13
14
  import { attachCommandShortcuts } from "../browser/command-shortcuts";
14
15
  import { navigationSearchItems } from "../browser/navigation-search";
16
+ import type { PanelRequest } from "../browser/panel";
15
17
  import { importOnDemand } from "../browser/reload";
16
18
  import { registerGlobalSearchHost } from "../browser/search-bridge";
17
19
  import { attachShellFocusControls, readShellFocus, SHELL_FOCUS_CHANGE_EVENT, toggleShellFocus } from "../browser/shell-focus";
@@ -32,6 +34,8 @@ import { shellShortcuts } from "./shell-shortcuts";
32
34
  import { syncTimezoneCookie } from "./timezone-cookie";
33
35
 
34
36
  export type ShellRuntimeProps = {
37
+ /** This page has an authorized panel slot. */
38
+ panel?: boolean;
35
39
  /** Layout pages outside focus mode: keep the current app's workspace layout in its cookie. */
36
40
  workspace?: { appId?: string };
37
41
  /** Layout pages: page titles, the way up (Alt+Up) and its per-tab memory. */
@@ -70,6 +74,55 @@ export default function ShellRuntime(props: ShellRuntimeProps) {
70
74
  onMount(() => syncTimezoneCookie(true));
71
75
  onMount(() => onCleanup(installInputModeController()));
72
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
+ }
125
+
73
126
  if (props.workspace) {
74
127
  onMount(() => {
75
128
  const appId = props.workspace?.appId;
@@ -146,15 +199,17 @@ export default function ShellRuntime(props: ShellRuntimeProps) {
146
199
  ),
147
200
  );
148
201
  createEffect(() => {
149
- for (const command of shellShortcuts(t())) {
202
+ for (const command of shellShortcuts(t(), { panel: props.panel })) {
150
203
  onCleanup(
151
204
  registerContextAwareCommand({
152
205
  ...command,
153
206
  action:
154
- command.id === "cloud.search"
155
- ? { search: {} }
156
- : // Returning the load keeps the Command busy until the dialog is open; a second press opens no second dialog.
157
- () => importOnDemand(() => import("./LayoutHelp")).then((help) => help?.openLayoutHelpDialog()),
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()),
158
213
  }),
159
214
  );
160
215
  }
@@ -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
+ };