@k2b/cloud 0.32.0 → 0.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -5
- package/src/_internal/app-presentation.ts +40 -11
- package/src/_internal/define-app.ts +18 -8
- package/src/_internal/page-responses.ts +5 -1
- package/src/_internal/registry.ts +10 -1
- package/src/_internal/widget-declarations.ts +16 -3
- package/src/_internal/widget-settings.ts +93 -0
- package/src/ai/capabilities.ts +8 -5
- package/src/ai/code-source-contracts.ts +17 -2
- package/src/ai/code-source-tools.ts +1 -0
- package/src/ai/default-tools.ts +4 -4
- package/src/ai/memories.ts +48 -1
- package/src/ai/memory-learning.ts +17 -6
- package/src/ai/memory-tool.ts +1 -0
- package/src/ai/migrate.ts +8 -0
- package/src/ai/provider.ts +4 -3
- package/src/ai/resource-refs.ts +35 -3
- package/src/ai/system-prompt.ts +3 -2
- package/src/api/index.ts +2 -0
- package/src/api/mcp.ts +48 -17
- package/src/api/pwa.ts +7 -2
- package/src/api/resource-links.ts +43 -0
- package/src/api/widgets.ts +163 -82
- package/src/browser/CloudResourceSearch.tsx +6 -6
- package/src/browser/command-shortcuts.ts +2 -11
- package/src/browser/navigation-search.ts +4 -1
- package/src/browser/resource-picker.tsx +1 -0
- package/src/browser/resource-search.ts +7 -2
- package/src/browser/widgets.ts +23 -5
- package/src/capabilities/testing.ts +13 -1
- package/src/cli/capabilities.ts +61 -17
- package/src/cli/index.ts +2 -0
- package/src/cli/resource-links.ts +13 -0
- package/src/contracts/app.ts +7 -1
- package/src/contracts/capabilities.ts +25 -5
- package/src/contracts/registry.ts +3 -1
- package/src/contracts/resource-href.ts +7 -0
- package/src/contracts/widgets.ts +80 -6
- package/src/desktop/index.ts +3 -2
- package/src/desktop/solid.tsx +7 -5
- package/src/server/widget-request.ts +34 -5
- package/src/services/app-approval.ts +4 -2
- package/src/services/approvals/index.ts +40 -11
- package/src/services/approvals/list.ts +1 -0
- package/src/services/approvals/migrate.ts +10 -0
- package/src/services/outbox.ts +13 -6
- package/src/services/pdf/markdown.ts +11 -7
- package/src/services/pwa-devices.ts +68 -15
- package/src/shared/ai-platform-prompt.ts +3 -2
- package/src/shared/app-presentation.ts +49 -1
- package/src/shared/index.ts +1 -0
- package/src/shared/markdown/callout-sanitizer.ts +37 -0
- package/src/shared/markdown/index.ts +8 -2
- package/src/shared/theme.ts +41 -20
- package/src/ssr/AnnouncementBanners.tsx +30 -0
- package/src/ssr/AppLaunchpadPanel.tsx +2 -2
- package/src/ssr/CloudShell.island.tsx +91 -0
- package/src/ssr/GlobalSearchTrigger.tsx +51 -0
- package/src/ssr/Layout.tsx +58 -99
- package/src/ssr/LayoutHeader.tsx +31 -77
- package/src/ssr/LayoutHelp.browser-harness.tsx +43 -0
- package/src/ssr/LayoutHelp.tsx +319 -238
- package/src/ssr/LayoutHelpTrigger.tsx +49 -0
- package/src/ssr/LayoutPreferences.tsx +31 -0
- package/src/ssr/LayoutRail.tsx +15 -69
- package/src/ssr/MinimalLayout.tsx +2 -3
- package/src/ssr/MinimalLayoutPreferences.island.tsx +6 -15
- package/src/ssr/MobileNavigation.tsx +13 -8
- package/src/ssr/MobileProfileActions.tsx +4 -6
- package/src/ssr/PageTrailRow.tsx +14 -0
- package/src/ssr/{ProfilePreferences.island.tsx → ProfilePreferences.tsx} +8 -5
- package/src/ssr/PwaLayout.tsx +0 -2
- package/src/ssr/PwaRuntime.island.tsx +1 -1
- package/src/ssr/ShellRuntime.island.tsx +166 -0
- package/src/ssr/ThemeCycleButton.tsx +46 -0
- package/src/ssr/TimezoneCookie.island.tsx +3 -19
- package/src/ssr/WorkspaceNavigationProvider.tsx +20 -1
- package/src/ssr/announcements-dialog.tsx +66 -0
- package/src/ssr/app-appearance.ts +6 -1
- package/src/ssr/{AppLaunchpad.island.tsx → app-launchpad.tsx} +42 -62
- package/src/ssr/busy-action.ts +19 -0
- package/src/ssr/islands/index.ts +9 -6
- package/src/ssr/launchpad-surface.tsx +15 -0
- package/src/ssr/layout-help-registry.tsx +60 -0
- package/src/ssr/layout-help-url.ts +39 -0
- package/src/ssr/mobile-navigation.browser-harness.tsx +20 -0
- package/src/ssr/platform-messages.ts +0 -8
- package/src/ssr/preference-controller.ts +62 -19
- package/src/ssr/profile-actions.ts +12 -5
- package/src/ssr/profile-preferences-messages.ts +10 -4
- package/src/ssr/rail-context.ts +8 -12
- package/src/ssr/rail-navigation.ts +3 -0
- package/src/ssr/shell-data.tsx +48 -0
- package/src/ssr/timezone-cookie.ts +22 -0
- package/src/ssr/workspace-navigation.ts +25 -2
- package/src/styles/effects.css +13 -3
- package/src/styles/global.css +43 -5
- package/src/styles/input.css +0 -8
- package/src/styles/tokens.css +0 -2
- package/src/styles/utilities-navigation.css +10 -0
- package/src/workflows/language/compiler.ts +1 -1
- package/src/ssr/AppWorkspaceController.island.tsx +0 -21
- package/src/ssr/BrowserPushRegistration.island.tsx +0 -12
- package/src/ssr/GlobalAnnouncements.island.tsx +0 -117
- package/src/ssr/GlobalSearchHelpDialog.tsx +0 -104
- package/src/ssr/GlobalSearchTrigger.island.tsx +0 -75
- package/src/ssr/LayoutHelpTrigger.island.tsx +0 -61
- package/src/ssr/LayoutPreferences.island.tsx +0 -21
- package/src/ssr/PageTrailRow.island.tsx +0 -20
- package/src/ssr/PageTrailRuntime.island.tsx +0 -26
- package/src/ssr/RegisteredHelpDocuments.island.tsx +0 -3
- /package/src/ssr/{LayoutBreadcrumbs.island.tsx → LayoutBreadcrumbs.tsx} +0 -0
- /package/src/ssr/{RailApps.island.tsx → RailApps.tsx} +0 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { IconButton, Tooltip, useLocale } from "@k2b/ui";
|
|
2
|
+
import { importOnDemand } from "../browser/reload";
|
|
3
|
+
import { createBusyAction } from "./busy-action";
|
|
4
|
+
import { platformMessages } from "./platform-messages";
|
|
5
|
+
|
|
6
|
+
type LayoutHelpTriggerProps = {
|
|
7
|
+
variant: "header" | "rail";
|
|
8
|
+
class?: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/** Opens end-user help from either the desktop rail or the compact header. */
|
|
12
|
+
export default function LayoutHelpTrigger(props: LayoutHelpTriggerProps) {
|
|
13
|
+
const locale = useLocale();
|
|
14
|
+
const t = () => platformMessages.resolve([locale()]).t;
|
|
15
|
+
const action = createBusyAction();
|
|
16
|
+
const openHelp = () =>
|
|
17
|
+
action.run(
|
|
18
|
+
() => importOnDemand(() => import("./LayoutHelp")),
|
|
19
|
+
(help) => help.openLayoutHelpDialog(),
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
if (props.variant === "rail")
|
|
23
|
+
return (
|
|
24
|
+
<Tooltip.Trigger
|
|
25
|
+
type="button"
|
|
26
|
+
class={`rail-item text-blue-500 hover:bg-blue-500/10 hover:text-blue-600 dark:text-blue-400 dark:hover:bg-blue-500/15 dark:hover:text-blue-300 ${props.class ?? ""}`}
|
|
27
|
+
onClick={openHelp}
|
|
28
|
+
aria-busy={action.busy() ? "true" : undefined}
|
|
29
|
+
aria-label={t().openHelp}
|
|
30
|
+
placement="right"
|
|
31
|
+
delay={0}
|
|
32
|
+
content={t().helpShortcut}
|
|
33
|
+
>
|
|
34
|
+
<i class={`${action.busy() ? "ti ti-loader-2 animate-spin" : "ti ti-help-circle"} text-base`} />
|
|
35
|
+
</Tooltip.Trigger>
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<IconButton
|
|
40
|
+
class={props.class}
|
|
41
|
+
onClick={openHelp}
|
|
42
|
+
aria-busy={action.busy() ? "true" : undefined}
|
|
43
|
+
label={t().openHelp}
|
|
44
|
+
title={t().helpShortcut}
|
|
45
|
+
>
|
|
46
|
+
<i class={`${action.busy() ? "ti ti-loader-2 animate-spin" : "ti ti-help-circle"} text-base`} />
|
|
47
|
+
</IconButton>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Dropdown, type DropdownPosition, useLocale } from "@k2b/ui";
|
|
2
|
+
import type { CloudThemePreference } from "../shared/theme";
|
|
3
|
+
import { createPreferenceController } from "./preference-controller";
|
|
4
|
+
|
|
5
|
+
type LayoutPreferencesProps = {
|
|
6
|
+
initialTheme: CloudThemePreference;
|
|
7
|
+
position: DropdownPosition;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export default function LayoutPreferences(props: LayoutPreferencesProps) {
|
|
11
|
+
const locale = useLocale();
|
|
12
|
+
const preferences = createPreferenceController(props.initialTheme, locale);
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
<>
|
|
16
|
+
<Dropdown.Root
|
|
17
|
+
items={preferences.items()}
|
|
18
|
+
label={preferences.messages().preferencesMenuLabel}
|
|
19
|
+
position={props.position}
|
|
20
|
+
width="14rem"
|
|
21
|
+
>
|
|
22
|
+
<Dropdown.Trigger iconOnly label={preferences.messages().preferencesMenuLabel} size="sm" variant="secondary">
|
|
23
|
+
<i class="ti ti-adjustments-horizontal" aria-hidden="true" />
|
|
24
|
+
</Dropdown.Trigger>
|
|
25
|
+
</Dropdown.Root>
|
|
26
|
+
<span class="sr-only" aria-live="polite">
|
|
27
|
+
{preferences.themeAnnouncement()}
|
|
28
|
+
</span>
|
|
29
|
+
</>
|
|
30
|
+
);
|
|
31
|
+
}
|
package/src/ssr/LayoutRail.tsx
CHANGED
|
@@ -1,75 +1,21 @@
|
|
|
1
|
-
import
|
|
2
|
-
import
|
|
3
|
-
import
|
|
4
|
-
import AppLaunchpad, { type AppLaunchpadApp } from "./AppLaunchpad.island";
|
|
5
|
-
import type { GlobalSearchHelpApp } from "./GlobalSearchHelpDialog";
|
|
6
|
-
import GlobalSearchTrigger from "./GlobalSearchTrigger.island";
|
|
7
|
-
import LayoutHelpTrigger from "./LayoutHelpTrigger.island";
|
|
8
|
-
import ProfilePreferences from "./ProfilePreferences.island";
|
|
9
|
-
import RailApps from "./RailApps.island";
|
|
1
|
+
import { useLocale } from "@k2b/ui";
|
|
2
|
+
import CloudShell from "./CloudShell.island";
|
|
3
|
+
import { platformMessages } from "./platform-messages";
|
|
10
4
|
import type { RailApp } from "./rail-navigation";
|
|
11
5
|
export type LayoutAppLink = RailApp;
|
|
12
6
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
appsLabel: string;
|
|
18
|
-
homeLabel: string;
|
|
19
|
-
launchpadApps: AppLaunchpadApp[];
|
|
20
|
-
legalLinks: Array<{ label: string; href: string; icon?: string }>;
|
|
21
|
-
openAppsLabel: string;
|
|
22
|
-
apps: RailApp[];
|
|
23
|
-
railSettings: RailSnapshot;
|
|
24
|
-
/** `pathname + search` of the request; the island resolves it against the browser origin. */
|
|
25
|
-
currentPath: string;
|
|
26
|
-
profileAvatarSrc?: string;
|
|
27
|
-
profileName: string;
|
|
28
|
-
searchHelpApps: GlobalSearchHelpApp[];
|
|
29
|
-
searchLinks?: NavigationSearchItem[];
|
|
30
|
-
theme: CloudTheme;
|
|
31
|
-
};
|
|
32
|
-
|
|
33
|
-
const jsonScript = (value: unknown): string => JSON.stringify(value).replace(/</g, "\\u003c");
|
|
34
|
-
|
|
35
|
-
export default function LayoutRail(props: LayoutRailProps) {
|
|
36
|
-
const profile = { name: props.profileName, theme: props.theme, ...(props.appHref ? { appHref: props.appHref } : {}) };
|
|
7
|
+
/** The rail frame and Home stay server markup; only the navigation is interactive. */
|
|
8
|
+
export default function LayoutRail(props: { currentPath: string }) {
|
|
9
|
+
const locale = useLocale();
|
|
10
|
+
const t = () => platformMessages.resolve([locale()]).t;
|
|
37
11
|
return (
|
|
38
|
-
|
|
39
|
-
<
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
<aside class="layout-rail hidden w-10 shrink-0 flex-col md:flex">
|
|
47
|
-
<div class="layout-rail-logo flex h-[2.875rem] shrink-0 items-center justify-center">
|
|
48
|
-
<a href="/" aria-label={props.homeLabel}>
|
|
49
|
-
<img src="/branding/logo" alt="Logo" class="h-5 w-5" />
|
|
50
|
-
</a>
|
|
51
|
-
</div>
|
|
52
|
-
<nav class="layout-rail-navigation flex min-h-0 flex-1 flex-col items-center gap-1" aria-label={props.appsLabel}>
|
|
53
|
-
<RailApps apps={props.apps} settings={props.railSettings} currentPath={props.currentPath} />
|
|
54
|
-
<AppLaunchpad
|
|
55
|
-
profile={profile}
|
|
56
|
-
apps={props.launchpadApps}
|
|
57
|
-
legalLinks={props.legalLinks}
|
|
58
|
-
variant="rail"
|
|
59
|
-
label={props.openAppsLabel}
|
|
60
|
-
/>
|
|
61
|
-
<div class="mt-auto shrink-0 flex flex-col items-center gap-1">
|
|
62
|
-
<GlobalSearchTrigger variant="rail" searchLinks={props.searchLinks} />
|
|
63
|
-
<LayoutHelpTrigger variant="rail" registerCommand searchHelpApps={props.searchHelpApps} accent={props.accent} />
|
|
64
|
-
<ProfilePreferences
|
|
65
|
-
appHref={props.appHref}
|
|
66
|
-
avatarSrc={props.profileAvatarSrc}
|
|
67
|
-
initialTheme={props.theme}
|
|
68
|
-
name={props.profileName}
|
|
69
|
-
/>
|
|
70
|
-
</div>
|
|
71
|
-
</nav>
|
|
72
|
-
</aside>
|
|
73
|
-
</>
|
|
12
|
+
<aside class="layout-rail hidden w-10 shrink-0 flex-col md:flex">
|
|
13
|
+
<div class="layout-rail-logo flex h-[2.875rem] shrink-0 items-center justify-center">
|
|
14
|
+
<a href="/" aria-label={t().home}>
|
|
15
|
+
<img src="/branding/logo" alt="Logo" class="h-5 w-5" />
|
|
16
|
+
</a>
|
|
17
|
+
</div>
|
|
18
|
+
<CloudShell slot="rail" currentPath={props.currentPath} />
|
|
19
|
+
</aside>
|
|
74
20
|
);
|
|
75
21
|
}
|
|
@@ -2,7 +2,7 @@ import { LocaleProvider } from "@k2b/ui";
|
|
|
2
2
|
import type { JSX } from "solid-js/jsx-runtime";
|
|
3
3
|
import { getLocale } from "../server/locale";
|
|
4
4
|
import { resolveAppPresentations } from "../shared/app-presentation";
|
|
5
|
-
import {
|
|
5
|
+
import { readThemePreferenceFromCookieHeader } from "../shared/theme";
|
|
6
6
|
import type { MinimalLayoutContext } from "./layout-context";
|
|
7
7
|
import MinimalLayoutPreferences from "./MinimalLayoutPreferences.island";
|
|
8
8
|
import { profilePreferencesMessages } from "./profile-preferences-messages";
|
|
@@ -26,9 +26,8 @@ export type MinimalLayoutProps = {
|
|
|
26
26
|
|
|
27
27
|
export default function MinimalLayout(props: MinimalLayoutProps) {
|
|
28
28
|
const cookie = props.c.req.raw.headers.get("Cookie") ?? "";
|
|
29
|
-
const theme =
|
|
29
|
+
const theme = readThemePreferenceFromCookieHeader(cookie);
|
|
30
30
|
const locale = getLocale(props.c);
|
|
31
|
-
props.c.get("page").theme = theme;
|
|
32
31
|
|
|
33
32
|
if (props.preferences === false) {
|
|
34
33
|
return (
|
|
@@ -1,18 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Dropdown, useLocale } from "@k2b/ui";
|
|
2
2
|
import { canonicalLocale } from "../shared/locale";
|
|
3
3
|
import { type ProfilePreferenceLocale, profilePreferenceLocale, setLocalePreference } from "../shared/locale-preference";
|
|
4
|
-
import type {
|
|
4
|
+
import type { CloudThemePreference } from "../shared/theme";
|
|
5
5
|
import { createPreferenceController } from "./preference-controller";
|
|
6
|
+
import { ThemeCycleButton } from "./ThemeCycleButton";
|
|
6
7
|
|
|
7
|
-
/**
|
|
8
|
-
|
|
9
|
-
* language trigger names the current language; the theme button names the
|
|
10
|
-
* mode it switches to, like every other Cloud preference menu. Below `md` both
|
|
11
|
-
* are compact, the language code and the theme icon with the full names as
|
|
12
|
-
* accessible labels, so they fit next to the legal links on a phone. CSS picks
|
|
13
|
-
* the form, so the server response is already final.
|
|
14
|
-
*/
|
|
15
|
-
export default function MinimalLayoutPreferences(props: { initialTheme: CloudTheme }) {
|
|
8
|
+
/** Language opens a menu; appearance cycles in place and keeps its accessible name below md. */
|
|
9
|
+
export default function MinimalLayoutPreferences(props: { initialTheme: CloudThemePreference }) {
|
|
16
10
|
const locale = useLocale();
|
|
17
11
|
const preferences = createPreferenceController(props.initialTheme, locale);
|
|
18
12
|
const t = preferences.messages;
|
|
@@ -43,10 +37,7 @@ export default function MinimalLayoutPreferences(props: { initialTheme: CloudThe
|
|
|
43
37
|
<i class="ti ti-chevron-down hidden md:inline" aria-hidden="true" />
|
|
44
38
|
</Dropdown.Trigger>
|
|
45
39
|
</Dropdown.Root>
|
|
46
|
-
<
|
|
47
|
-
<i class={preferences.theme() === "light" ? "ti ti-moon" : "ti ti-sun-high"} aria-hidden="true" />
|
|
48
|
-
<span class="hidden md:inline">{preferences.themeLabel()}</span>
|
|
49
|
-
</Button>
|
|
40
|
+
<ThemeCycleButton preferences={preferences} size="sm" variant="ghost" compact />
|
|
50
41
|
</div>
|
|
51
42
|
);
|
|
52
43
|
}
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { BottomSheet, Navigation, SegmentedControl, useLocale } from "@k2b/ui";
|
|
2
2
|
import { createSignal, onCleanup, Show } from "solid-js";
|
|
3
3
|
import { type AppLaunchpadContext, AppLaunchpadPanel } from "./AppLaunchpadPanel";
|
|
4
|
+
import { appAccentStyle } from "./app-appearance";
|
|
4
5
|
import { openMobileMenu } from "./mobile-menu-history";
|
|
5
6
|
import { platformMessages } from "./platform-messages";
|
|
7
|
+
import { readShellData } from "./shell-data";
|
|
6
8
|
import { observeWorkspaceNavigation, readWorkspaceNavigation } from "./workspace-navigation";
|
|
7
9
|
|
|
8
10
|
export function openCloudMobileMenu(apps: AppLaunchpadContext) {
|
|
@@ -58,14 +60,17 @@ function MobileNavigation(props: {
|
|
|
58
60
|
when={workspace() && tab() === "app"}
|
|
59
61
|
fallback={<AppLaunchpadPanel surface="sheet" {...props.apps} close={props.close} beforeSelect={props.beforeSelect} />}
|
|
60
62
|
>
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
63
|
+
{/* The sheet opens outside the app canvas, so the app menu takes the app's accent here, as the canvas does. */}
|
|
64
|
+
<div class="app-accent-scope" style={appAccentStyle(readShellData()?.accent)}>
|
|
65
|
+
<Navigation
|
|
66
|
+
navigation={workspace()!.navigation}
|
|
67
|
+
label={workspace()!.label || t().appMenu}
|
|
68
|
+
beforeSelect={async () => {
|
|
69
|
+
const owner = workspace()?.owner;
|
|
70
|
+
return (await props.beforeSelect()) && Boolean(owner?.isConnected && readWorkspaceNavigation()?.owner === owner);
|
|
71
|
+
}}
|
|
72
|
+
/>
|
|
73
|
+
</div>
|
|
69
74
|
</Show>
|
|
70
75
|
</BottomSheet.Body>
|
|
71
76
|
</BottomSheet>
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { documentNavigate } from "@k2b/ssr/nav";
|
|
2
2
|
import { Button, ButtonLink } from "@k2b/ui";
|
|
3
|
-
import type {
|
|
3
|
+
import type { CloudThemePreference } from "../shared/theme";
|
|
4
4
|
import { createProfileActions } from "./profile-actions";
|
|
5
|
+
import { ThemeCycleButton } from "./ThemeCycleButton";
|
|
5
6
|
|
|
6
7
|
export function MobileProfileActions(props: {
|
|
7
|
-
profile: { name: string; theme:
|
|
8
|
+
profile: { name: string; theme: CloudThemePreference; appHref?: string };
|
|
8
9
|
beforeSelect: () => Promise<boolean>;
|
|
9
10
|
}) {
|
|
10
11
|
const { preferences, signingOut, signOut } = createProfileActions(props.profile.theme, props.profile.appHref);
|
|
@@ -17,6 +18,7 @@ export function MobileProfileActions(props: {
|
|
|
17
18
|
};
|
|
18
19
|
return (
|
|
19
20
|
<section class="cloud-mobile-profile" aria-label={preferences.messages().menuLabel}>
|
|
21
|
+
<ThemeCycleButton preferences={preferences} variant="ghost" />
|
|
20
22
|
<ButtonLink href="/me" variant="ghost" onClick={navigate("/me")}>
|
|
21
23
|
<i class="ti ti-user-circle" aria-hidden="true" />
|
|
22
24
|
{preferences.messages().profileSettings}
|
|
@@ -27,10 +29,6 @@ export function MobileProfileActions(props: {
|
|
|
27
29
|
{preferences.messages().app}
|
|
28
30
|
</ButtonLink>
|
|
29
31
|
)}
|
|
30
|
-
<Button variant="ghost" onClick={preferences.toggleTheme}>
|
|
31
|
-
<i class={`ti ${preferences.theme() === "light" ? "ti-moon" : "ti-sun-high"}`} aria-hidden="true" />
|
|
32
|
-
{preferences.themeLabel()}
|
|
33
|
-
</Button>
|
|
34
32
|
<Button
|
|
35
33
|
variant="ghost"
|
|
36
34
|
onClick={async () => {
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { PageTitle, type PageTrailState } from "@k2b/ui";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The shell's own place for the way up, above pages without a workspace. It
|
|
5
|
+
* shows only on a page with a page above it, and steps back when the page
|
|
6
|
+
* renders a workspace (whose head shows it) or its own `PageTrail`.
|
|
7
|
+
*/
|
|
8
|
+
export default function PageTrailRow(props: { trail: PageTrailState }) {
|
|
9
|
+
return (
|
|
10
|
+
<div class="layout-page-head" data-trail={props.trail.ancestors.length ? "up" : "title"}>
|
|
11
|
+
<PageTitle trail={props.trail} as="p" fallback />
|
|
12
|
+
</div>
|
|
13
|
+
);
|
|
14
|
+
}
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { Avatar, Dropdown } from "@k2b/ui";
|
|
2
|
-
import type {
|
|
2
|
+
import type { CloudThemePreference } from "../shared/theme";
|
|
3
3
|
import { createProfileActions } from "./profile-actions";
|
|
4
4
|
|
|
5
5
|
type ProfilePreferencesProps = {
|
|
6
6
|
/** Set while the installation offers the mobile app. */
|
|
7
7
|
appHref?: string;
|
|
8
8
|
avatarSrc?: string;
|
|
9
|
-
initialTheme:
|
|
9
|
+
initialTheme: CloudThemePreference;
|
|
10
10
|
name: string;
|
|
11
11
|
};
|
|
12
12
|
|
|
@@ -26,9 +26,9 @@ export default function ProfilePreferences(props: ProfilePreferencesProps) {
|
|
|
26
26
|
{avatar()}
|
|
27
27
|
</button>
|
|
28
28
|
<div class="layout-profile-preferences__panel dropdown-menu-surface">
|
|
29
|
-
<button type="button" class="menu-item" onClick={preferences.
|
|
30
|
-
<i class={
|
|
31
|
-
<span>{preferences.
|
|
29
|
+
<button type="button" class="menu-item" aria-label={preferences.themeCycleLabel()} onClick={preferences.cycleTheme}>
|
|
30
|
+
<i class={preferences.themeOption(preferences.theme()).icon} aria-hidden="true" />
|
|
31
|
+
<span>{preferences.themeOption(preferences.theme()).label}</span>
|
|
32
32
|
</button>
|
|
33
33
|
<button type="button" class="menu-item" onClick={preferences.toggleLanguage}>
|
|
34
34
|
<i class="ti ti-language" aria-hidden="true" />
|
|
@@ -66,6 +66,9 @@ export default function ProfilePreferences(props: ProfilePreferencesProps) {
|
|
|
66
66
|
{avatar()}
|
|
67
67
|
</Dropdown.Trigger>
|
|
68
68
|
</Dropdown.Root>
|
|
69
|
+
<span class="sr-only" aria-live="polite">
|
|
70
|
+
{preferences.themeAnnouncement()}
|
|
71
|
+
</span>
|
|
69
72
|
</div>
|
|
70
73
|
);
|
|
71
74
|
}
|
package/src/ssr/PwaLayout.tsx
CHANGED
|
@@ -2,7 +2,6 @@ import { LocaleProvider, MobileShell, TabBar, type TabBarItem } from "@k2b/ui";
|
|
|
2
2
|
import type { JSX } from "solid-js";
|
|
3
3
|
import { PWA_SCOPE } from "../contracts/pwa";
|
|
4
4
|
import { getLocale } from "../server/locale";
|
|
5
|
-
import { readThemeFromCookieHeader } from "../shared/theme";
|
|
6
5
|
import { visiblePwaParts } from "./app-navigation";
|
|
7
6
|
import type { LayoutContext } from "./layout-context";
|
|
8
7
|
import PwaRuntime from "./PwaRuntime.island";
|
|
@@ -37,7 +36,6 @@ export default function PwaLayout(props: PwaLayoutProps) {
|
|
|
37
36
|
const user = c.get("user");
|
|
38
37
|
const cloud = c.get("settings")?.app?.name || "Cloud";
|
|
39
38
|
const page = c.get("page");
|
|
40
|
-
page.theme = readThemeFromCookieHeader(c.req.raw.headers.get("Cookie"));
|
|
41
39
|
page.title = props.title;
|
|
42
40
|
page.pwa = { name: cloud };
|
|
43
41
|
// No app page may be framed, and none leaks its address to another site.
|
|
@@ -7,7 +7,7 @@ import { pwaMessages } from "./pwa-messages";
|
|
|
7
7
|
/** Set once per browsing session, so the first page renews and later navigations do not. */
|
|
8
8
|
const RENEWED_MARKER = "cloud.pwa.renewed";
|
|
9
9
|
const KEEPALIVE_INTERVAL_MS = 60 * 60 * 1000;
|
|
10
|
-
/**
|
|
10
|
+
/** Fires for preference changes and system scheme changes from the head script. */
|
|
11
11
|
const THEME_PREFERENCE_EVENT = "cloud:theme-preference";
|
|
12
12
|
|
|
13
13
|
export type PwaRuntimeProps = {
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import type { HelpDocumentManifest } from "@k2b/cloud/shared";
|
|
2
|
+
import {
|
|
3
|
+
followPageTrail,
|
|
4
|
+
installAppWorkspaceController,
|
|
5
|
+
installPageTitleController,
|
|
6
|
+
serializeAppWorkspaceLayoutState,
|
|
7
|
+
useLocale,
|
|
8
|
+
} from "@k2b/ui";
|
|
9
|
+
import { createEffect, onCleanup, onMount } from "solid-js";
|
|
10
|
+
import { appWorkspaceCookieName, readAppWorkspaceLayoutCookie } from "../_internal/app-workspace-state";
|
|
11
|
+
import { registerContextAwareCommand } from "../browser/command-bridge";
|
|
12
|
+
import { attachCommandShortcuts } from "../browser/command-shortcuts";
|
|
13
|
+
import { navigationSearchItems } from "../browser/navigation-search";
|
|
14
|
+
import { importOnDemand } from "../browser/reload";
|
|
15
|
+
import { registerGlobalSearchHost } from "../browser/search-bridge";
|
|
16
|
+
import {
|
|
17
|
+
ANNOUNCEMENTS_COOKIE,
|
|
18
|
+
ANNOUNCEMENTS_COOKIE_MAX_AGE_SECONDS,
|
|
19
|
+
mergeAnnouncementCookieState,
|
|
20
|
+
parseAnnouncementCookieHeader,
|
|
21
|
+
serializeAnnouncementCookieState,
|
|
22
|
+
} from "../contracts/announcement-cookie";
|
|
23
|
+
import type { AnnouncementDisplayEntry } from "../contracts/announcements";
|
|
24
|
+
import { createGlobalSearchHost } from "./global-search-host";
|
|
25
|
+
import { LayoutHelpDocuments } from "./layout-help-registry";
|
|
26
|
+
import { installPageTrailRuntime } from "./page-trail-runtime";
|
|
27
|
+
import { platformMessages } from "./platform-messages";
|
|
28
|
+
import { readShellData } from "./shell-data";
|
|
29
|
+
import { syncTimezoneCookie } from "./timezone-cookie";
|
|
30
|
+
|
|
31
|
+
export type ShellRuntimeProps = {
|
|
32
|
+
/** Layout pages outside focus mode: keep the current app's workspace layout in its cookie. */
|
|
33
|
+
workspace?: { appId?: string };
|
|
34
|
+
/** Layout pages: page titles, the way up (Alt+Up) and its per-tab memory. */
|
|
35
|
+
pageTrail?: boolean;
|
|
36
|
+
/** The current app's Help documents. */
|
|
37
|
+
help?: { documents: readonly HelpDocumentManifest[]; pageBase: string };
|
|
38
|
+
/** Layout pages outside focus mode: the search host, Cmd/Ctrl+K, the Help command (Shift+/) and command shortcuts. */
|
|
39
|
+
commands?: { searchResources: boolean };
|
|
40
|
+
/** Signed-in pages: refresh this browser's push endpoint. */
|
|
41
|
+
push?: boolean;
|
|
42
|
+
/** Announcements the person has not seen yet; a dialog shows them once. */
|
|
43
|
+
announcements?: { entries: AnnouncementDisplayEntry[]; latestVersion: number };
|
|
44
|
+
/** Banners rendered by Layout: their dismiss buttons record the dismissal. */
|
|
45
|
+
banners?: boolean;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** The search rows of the shell's destinations; the page carries each link once, without the fields a row repeats. */
|
|
49
|
+
const searchItems = () => {
|
|
50
|
+
const data = readShellData();
|
|
51
|
+
if (!data) return [];
|
|
52
|
+
const apps = Object.entries(data.searchLinks).flatMap(([id, searchLinks]) => {
|
|
53
|
+
const app = data.apps.find((candidate) => candidate.id === id);
|
|
54
|
+
return app ? [{ id, name: app.label, icon: app.iconClass, searchLinks }] : [];
|
|
55
|
+
});
|
|
56
|
+
return navigationSearchItems(
|
|
57
|
+
apps,
|
|
58
|
+
apps.map((app) => app.id),
|
|
59
|
+
);
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export default function ShellRuntime(props: ShellRuntimeProps) {
|
|
63
|
+
const locale = useLocale();
|
|
64
|
+
const t = () => platformMessages.resolve([locale()]).t;
|
|
65
|
+
onMount(() => syncTimezoneCookie(true));
|
|
66
|
+
|
|
67
|
+
if (props.workspace) {
|
|
68
|
+
onMount(() => {
|
|
69
|
+
const appId = props.workspace?.appId;
|
|
70
|
+
onCleanup(
|
|
71
|
+
installAppWorkspaceController({
|
|
72
|
+
readState: () => readAppWorkspaceLayoutCookie(document.cookie, appId),
|
|
73
|
+
writeState: (state) => {
|
|
74
|
+
if (!appId) return;
|
|
75
|
+
const secure = window.location.protocol === "https:" ? "; Secure" : "";
|
|
76
|
+
document.cookie = `${appWorkspaceCookieName(appId)}=${serializeAppWorkspaceLayoutState(state)}; Path=/; Max-Age=31536000; SameSite=Lax${secure}`;
|
|
77
|
+
},
|
|
78
|
+
}),
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
if (props.pageTrail) {
|
|
83
|
+
onMount(() => {
|
|
84
|
+
const messages = t();
|
|
85
|
+
const disposeTitles = installPageTitleController(document);
|
|
86
|
+
const disposeFollow = followPageTrail(document);
|
|
87
|
+
const disposeRuntime = installPageTrailRuntime((page) => ({
|
|
88
|
+
title: messages.pageUp({ page }),
|
|
89
|
+
description: messages.pageUpDescription,
|
|
90
|
+
}));
|
|
91
|
+
onCleanup(() => {
|
|
92
|
+
disposeTitles();
|
|
93
|
+
disposeFollow();
|
|
94
|
+
disposeRuntime();
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
if (props.commands) {
|
|
99
|
+
const host = createGlobalSearchHost(searchItems, props.commands.searchResources);
|
|
100
|
+
onMount(() => onCleanup(registerGlobalSearchHost(host.open, host.dispose)));
|
|
101
|
+
// Command schemas and the capability client load with the first shortcut.
|
|
102
|
+
onMount(() =>
|
|
103
|
+
onCleanup(
|
|
104
|
+
attachCommandShortcuts(
|
|
105
|
+
(command) =>
|
|
106
|
+
void importOnDemand(() => import("../browser/commands")).then((commands) => commands?.runContextAwareCommand(command)),
|
|
107
|
+
),
|
|
108
|
+
),
|
|
109
|
+
);
|
|
110
|
+
createEffect(() =>
|
|
111
|
+
onCleanup(
|
|
112
|
+
registerContextAwareCommand({
|
|
113
|
+
id: "cloud.search",
|
|
114
|
+
title: t().openGlobalSearch,
|
|
115
|
+
description: t().globalSearchDescription,
|
|
116
|
+
icon: "ti ti-search",
|
|
117
|
+
shortcut: "mod+k",
|
|
118
|
+
action: { search: {} },
|
|
119
|
+
}),
|
|
120
|
+
),
|
|
121
|
+
);
|
|
122
|
+
createEffect(() =>
|
|
123
|
+
onCleanup(
|
|
124
|
+
registerContextAwareCommand({
|
|
125
|
+
id: "cloud.help",
|
|
126
|
+
title: t().openShortcutHelp,
|
|
127
|
+
description: t().shortcutHelpDescription,
|
|
128
|
+
icon: "ti ti-help-circle",
|
|
129
|
+
shortcut: "shift+/",
|
|
130
|
+
// Returning the load keeps the Command busy until the dialog is open; a second press opens no second dialog.
|
|
131
|
+
action: () => importOnDemand(() => import("./LayoutHelp")).then((help) => help?.openLayoutHelpDialog()),
|
|
132
|
+
}),
|
|
133
|
+
),
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
onMount(() => {
|
|
137
|
+
if (props.push) {
|
|
138
|
+
void import("../browser/notifications")
|
|
139
|
+
.then(({ browserNotificationClient }) => browserNotificationClient.refreshExisting())
|
|
140
|
+
.catch((error) => {
|
|
141
|
+
console.warn("[notifications] Failed to refresh the browser endpoint", error);
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
if (props.announcements?.entries.length) {
|
|
145
|
+
const announcements = props.announcements;
|
|
146
|
+
void importOnDemand(() => import("./announcements-dialog")).then((loaded) => loaded?.openAnnouncementsDialog(announcements));
|
|
147
|
+
}
|
|
148
|
+
if (props.banners) {
|
|
149
|
+
const dismiss = (event: MouseEvent) => {
|
|
150
|
+
if (!(event.target instanceof Element)) return;
|
|
151
|
+
const button = event.target.closest<HTMLButtonElement>("[data-cloud-announcement-dismiss]");
|
|
152
|
+
if (!button) return;
|
|
153
|
+
const version = Number(button.dataset.cloudAnnouncementDismiss);
|
|
154
|
+
const next = mergeAnnouncementCookieState(parseAnnouncementCookieHeader(document.cookie), { dismissedBannerVersions: [version] });
|
|
155
|
+
document.cookie = `${ANNOUNCEMENTS_COOKIE}=${serializeAnnouncementCookieState(next)}; Path=/; Max-Age=${ANNOUNCEMENTS_COOKIE_MAX_AGE_SECONDS}; SameSite=Lax`;
|
|
156
|
+
const banner = button.closest(".cloud-announcement");
|
|
157
|
+
const list = banner?.parentElement;
|
|
158
|
+
banner?.remove();
|
|
159
|
+
if (list && !list.querySelector(".cloud-announcement")) list.remove();
|
|
160
|
+
};
|
|
161
|
+
document.addEventListener("click", dismiss);
|
|
162
|
+
onCleanup(() => document.removeEventListener("click", dismiss));
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
return props.help ? <LayoutHelpDocuments documents={props.help.documents} pageBase={props.help.pageBase} /> : null;
|
|
166
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { Button, type ButtonSize, type ButtonVariant } from "@k2b/ui";
|
|
2
|
+
import { type createPreferenceController, THEME_CYCLE } from "./preference-controller";
|
|
3
|
+
|
|
4
|
+
type Props = {
|
|
5
|
+
preferences: ReturnType<typeof createPreferenceController>;
|
|
6
|
+
variant: ButtonVariant;
|
|
7
|
+
size?: ButtonSize;
|
|
8
|
+
/** Shows only the icon below `md`; the accessible name still states the choice. */
|
|
9
|
+
compact?: boolean;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* One button for the appearance: it shows the current choice and each click switches to the next. The label reserves
|
|
14
|
+
* the width of the two other choices, so the button and its neighbors stay in place.
|
|
15
|
+
*/
|
|
16
|
+
export function ThemeCycleButton(props: Props) {
|
|
17
|
+
const current = () => props.preferences.themeOption(props.preferences.theme());
|
|
18
|
+
const otherLabels = () =>
|
|
19
|
+
THEME_CYCLE.filter((value) => value !== props.preferences.theme()).map((value) => props.preferences.themeOption(value).label);
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<>
|
|
23
|
+
<Button
|
|
24
|
+
type="button"
|
|
25
|
+
class="cloud-theme-cycle"
|
|
26
|
+
variant={props.variant}
|
|
27
|
+
size={props.size}
|
|
28
|
+
aria-label={props.preferences.themeCycleLabel()}
|
|
29
|
+
onClick={props.preferences.cycleTheme}
|
|
30
|
+
>
|
|
31
|
+
<i class={current().icon} aria-hidden="true" />
|
|
32
|
+
<span
|
|
33
|
+
class="cloud-theme-cycle__label"
|
|
34
|
+
data-compact={props.compact ? "true" : undefined}
|
|
35
|
+
data-reserve-a={otherLabels()[0]}
|
|
36
|
+
data-reserve-b={otherLabels()[1]}
|
|
37
|
+
>
|
|
38
|
+
<span>{current().label}</span>
|
|
39
|
+
</span>
|
|
40
|
+
</Button>
|
|
41
|
+
<span class="sr-only" aria-live="polite">
|
|
42
|
+
{props.preferences.themeAnnouncement()}
|
|
43
|
+
</span>
|
|
44
|
+
</>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
@@ -1,29 +1,13 @@
|
|
|
1
|
-
import { cookies } from "@k2b/stdlib/browser";
|
|
2
1
|
import { onMount } from "solid-js";
|
|
3
|
-
import {
|
|
2
|
+
import { syncTimezoneCookie } from "./timezone-cookie";
|
|
4
3
|
|
|
5
4
|
export type TimezoneCookieProps = {
|
|
6
5
|
/** Reload once after a timezone change, so the page shows times in the browser's zone. Defaults to `true`. */
|
|
7
6
|
reload?: boolean;
|
|
8
7
|
};
|
|
9
8
|
|
|
9
|
+
/** The timezone cookie for layouts without the shell runtime: `MinimalLayout` and `PwaLayout`. */
|
|
10
10
|
export default function TimezoneCookie(props: TimezoneCookieProps) {
|
|
11
|
-
onMount(() =>
|
|
12
|
-
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
13
|
-
const current = cookies.readCookie(TIMEZONE_COOKIE);
|
|
14
|
-
if (timeZone && current !== timeZone) {
|
|
15
|
-
cookies.writeCookie(TIMEZONE_COOKIE, timeZone);
|
|
16
|
-
if (props.reload === false) return;
|
|
17
|
-
try {
|
|
18
|
-
if (sessionStorage.getItem("cloud.timezone.reload") !== timeZone) {
|
|
19
|
-
sessionStorage.setItem("cloud.timezone.reload", timeZone);
|
|
20
|
-
window.location.reload();
|
|
21
|
-
}
|
|
22
|
-
} catch {
|
|
23
|
-
// Cookie persistence still succeeds; the next navigation will render with the browser timezone.
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
});
|
|
27
|
-
|
|
11
|
+
onMount(() => syncTimezoneCookie(props.reload !== false));
|
|
28
12
|
return null;
|
|
29
13
|
}
|