@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.
- package/package.json +6 -3
- package/scripts/build-canvas-workers.ts +109 -29
- package/scripts/build.ts +3 -5
- package/src/_internal/app-path.ts +25 -0
- package/src/_internal/app-presentation.ts +4 -0
- package/src/_internal/canvas-worker.ts +5 -3
- package/src/_internal/define-app.ts +28 -2
- package/src/_internal/panel-cookie.ts +39 -0
- package/src/_internal/registry-validation.ts +21 -26
- package/src/_internal/runtime-context.ts +1 -0
- package/src/access/PermissionEditor.browser-harness.tsx +57 -38
- package/src/access/PermissionEditor.tsx +30 -5
- package/src/access/ResourceApiKeys.tsx +5 -1
- package/src/ai/approval-routes.ts +5 -2
- package/src/ai/approvals.ts +139 -28
- package/src/ai/browser.ts +10 -0
- package/src/ai/chat/action-sentences.ts +1 -44
- package/src/ai/chat/turn-view.tsx +35 -3
- package/src/ai/chat/view-block.tsx +7 -2
- package/src/ai/chat-task-contracts.ts +28 -3
- package/src/ai/chat-task-reasons-migrate.ts +68 -0
- package/src/ai/chat-task-reasons.ts +92 -0
- package/src/ai/chat-tasks.ts +396 -66
- package/src/ai/index.ts +12 -0
- package/src/ai/migrate.ts +28 -2
- package/src/ai/request-context.ts +7 -1
- package/src/ai/routes.ts +27 -1
- package/src/ai/runtime.ts +12 -27
- package/src/ai/store.ts +5 -3
- package/src/ai/timeline.ts +9 -1
- package/src/ai/tool-wording.ts +45 -0
- package/src/ai/turn-abort.ts +33 -0
- package/src/ai/types.ts +12 -1
- package/src/api/admin-core-settings.ts +190 -139
- package/src/api/admin-outgoing-mail.ts +4 -2
- package/src/api/app-approval.ts +9 -2
- package/src/api/approvals.ts +1 -1
- package/src/api/index.ts +2 -1
- package/src/browser/CloudResourceSearch.tsx +10 -6
- package/src/browser/app-approval.ts +7 -2
- package/src/browser/composer-catalog.ts +103 -0
- package/src/browser/composer-insert-messages.ts +31 -0
- package/src/browser/composer-insert.ts +150 -0
- package/src/browser/panel-messages.ts +24 -0
- package/src/browser/panel-slot.ts +354 -0
- package/src/browser/panel.ts +28 -0
- package/src/browser/resource-picker.tsx +12 -1
- package/src/contracts/app-approval.ts +49 -9
- package/src/contracts/app.ts +14 -0
- package/src/contracts/registry.ts +3 -1
- package/src/contracts/shared.ts +2 -0
- package/src/server/services/access.ts +5 -1
- package/src/services/accounts/posix.ts +2 -1
- package/src/services/app-approval.ts +158 -38
- package/src/services/approvals/device.ts +2 -0
- package/src/services/index.ts +21 -3
- package/src/services/notifications/browser.ts +16 -5
- package/src/services/pdf/image-protocol.ts +135 -0
- package/src/services/pdf/image-variants.ts +124 -0
- package/src/services/pdf/image-worker.ts +31 -0
- package/src/services/pdf/image.ts +109 -0
- package/src/services/pdf/index.ts +9 -0
- package/src/services/settings/api.ts +11 -6
- package/src/services/settings/app.ts +5 -4
- package/src/services/settings/core-settings.ts +4 -3
- package/src/services/settings/index.ts +19 -17
- package/src/services/settings/namespace.ts +2 -1
- package/src/services/settings/store.ts +83 -11
- package/src/shared/ai-platform-prompt.ts +1 -1
- package/src/shared/app-presentation.ts +1 -0
- package/src/shared/redirect.ts +12 -0
- package/src/ssr/CloudShell.island.tsx +86 -9
- package/src/ssr/Layout.tsx +42 -9
- package/src/ssr/LayoutHeader.tsx +15 -9
- package/src/ssr/LayoutHelp.tsx +1 -1
- package/src/ssr/LayoutRail.tsx +2 -2
- package/src/ssr/LayoutTabBar.tsx +29 -0
- package/src/ssr/PanelLayout.tsx +27 -0
- package/src/ssr/PanelRuntime.island.tsx +87 -0
- package/src/ssr/PanelSlot.tsx +108 -0
- package/src/ssr/PanelTrigger.tsx +49 -0
- package/src/ssr/ShellRuntime.island.tsx +60 -5
- package/src/ssr/app-appearance.ts +3 -2
- package/src/ssr/app-color.ts +100 -0
- package/src/ssr/app-launchpad.tsx +18 -1
- package/src/ssr/app-navigation.ts +15 -0
- package/src/ssr/compact-shell.baseline.json +5270 -0
- package/src/ssr/compact-shell.browser-harness.ts +431 -0
- package/src/ssr/index.ts +3 -0
- package/src/ssr/panel-slot.browser-harness.tsx +110 -0
- package/src/ssr/platform-messages.ts +30 -0
- package/src/ssr/rail-navigation.ts +44 -0
- package/src/ssr/shell-data.tsx +4 -0
- package/src/ssr/shell-shortcuts.ts +13 -2
- package/src/styles/effects.css +3 -2
- package/src/styles/global.css +269 -55
- package/src/styles/panel.css +373 -0
- package/src/styles/tokens.css +23 -6
- 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.
|
|
155
|
-
?
|
|
156
|
-
:
|
|
157
|
-
|
|
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
|
+
};
|