@k2b/cloud 0.37.0 → 0.39.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (149) hide show
  1. package/package.json +8 -3
  2. package/scripts/build-canvas-workers.ts +109 -29
  3. package/scripts/build.ts +3 -5
  4. package/src/_internal/app-path.ts +25 -0
  5. package/src/_internal/app-presentation.ts +4 -0
  6. package/src/_internal/canvas-worker.ts +5 -3
  7. package/src/_internal/define-app.ts +28 -2
  8. package/src/_internal/help-catalog.ts +123 -4
  9. package/src/_internal/help.ts +27 -6
  10. package/src/_internal/panel-cookie.ts +39 -0
  11. package/src/_internal/registry-validation.ts +21 -26
  12. package/src/_internal/runtime-context.ts +1 -0
  13. package/src/access/PermissionEditor.browser-harness.tsx +57 -38
  14. package/src/access/PermissionEditor.tsx +30 -5
  15. package/src/access/ResourceApiKeys.tsx +5 -1
  16. package/src/account/Avatar.tsx +20 -2
  17. package/src/account/ui.ts +1 -1
  18. package/src/ai/approval-routes.ts +5 -2
  19. package/src/ai/approvals.ts +139 -28
  20. package/src/ai/browser.ts +11 -0
  21. package/src/ai/chart-block.ts +44 -13
  22. package/src/ai/chat/action-sentences.ts +1 -44
  23. package/src/ai/chat/chart-block.tsx +2 -11
  24. package/src/ai/chat/messages.ts +10 -0
  25. package/src/ai/chat/turn-view.tsx +35 -3
  26. package/src/ai/chat/view-block.tsx +7 -2
  27. package/src/ai/chat/web-tools.tsx +23 -6
  28. package/src/ai/chat-task-contracts.ts +28 -3
  29. package/src/ai/chat-task-reasons-migrate.ts +68 -0
  30. package/src/ai/chat-task-reasons.ts +92 -0
  31. package/src/ai/chat-tasks.ts +460 -66
  32. package/src/ai/default-tool-contracts.ts +30 -13
  33. package/src/ai/fetch-file-tool.ts +37 -3
  34. package/src/ai/index.ts +12 -0
  35. package/src/ai/migrate.ts +46 -3
  36. package/src/ai/readable-html.ts +1057 -0
  37. package/src/ai/request-context.ts +7 -1
  38. package/src/ai/routes.ts +27 -1
  39. package/src/ai/runtime.ts +12 -27
  40. package/src/ai/store.ts +5 -3
  41. package/src/ai/timeline.ts +9 -1
  42. package/src/ai/tool-wording.ts +45 -0
  43. package/src/ai/turn-abort.ts +33 -0
  44. package/src/ai/types.ts +12 -1
  45. package/src/api/admin-core-settings.ts +190 -139
  46. package/src/api/admin-outgoing-mail.ts +4 -2
  47. package/src/api/app-approval.ts +9 -2
  48. package/src/api/approvals.ts +1 -1
  49. package/src/api/help.ts +23 -4
  50. package/src/api/index.ts +2 -1
  51. package/src/api/mcp.ts +8 -2
  52. package/src/browser/CloudResourceSearch.tsx +10 -6
  53. package/src/browser/FileChooser.tsx +11 -3
  54. package/src/browser/app-approval.ts +7 -2
  55. package/src/browser/choose-files.browser-harness.tsx +4 -1
  56. package/src/browser/composer-catalog.ts +103 -0
  57. package/src/browser/composer-insert-messages.ts +31 -0
  58. package/src/browser/composer-insert.ts +150 -0
  59. package/src/browser/file-chooser-messages.ts +4 -0
  60. package/src/browser/file-providers.ts +14 -1
  61. package/src/browser/panel-messages.ts +24 -0
  62. package/src/browser/panel-slot.ts +354 -0
  63. package/src/browser/panel.ts +28 -0
  64. package/src/browser/provider-folder.tsx +58 -5
  65. package/src/browser/resource-picker.tsx +17 -2
  66. package/src/cli/admin/workflows.ts +1 -0
  67. package/src/config/env.ts +14 -0
  68. package/src/contracts/app-approval.ts +49 -9
  69. package/src/contracts/app.ts +14 -0
  70. package/src/contracts/file-provider.ts +48 -1
  71. package/src/contracts/outgoing-mail.ts +8 -1
  72. package/src/contracts/registry.ts +3 -1
  73. package/src/contracts/shared.ts +2 -0
  74. package/src/server/help.ts +136 -10
  75. package/src/server/services/access.ts +5 -1
  76. package/src/server/services/geo.ts +21 -4
  77. package/src/services/accounts/posix.ts +2 -1
  78. package/src/services/allowed-network-fetch.ts +189 -0
  79. package/src/services/app-approval.ts +158 -38
  80. package/src/services/approvals/device.ts +2 -0
  81. package/src/services/help/index.ts +46 -5
  82. package/src/services/help/store.ts +26 -5
  83. package/src/services/help/types.ts +10 -2
  84. package/src/services/index.ts +22 -3
  85. package/src/services/notifications/browser.ts +16 -5
  86. package/src/services/outgoing-mail/dispatcher.ts +7 -2
  87. package/src/services/outgoing-mail/resolve-host.ts +39 -0
  88. package/src/services/outgoing-mail/test-send.ts +18 -3
  89. package/src/services/outgoing-mail/transport.ts +24 -13
  90. package/src/services/pdf/image-protocol.ts +135 -0
  91. package/src/services/pdf/image-variants.ts +124 -0
  92. package/src/services/pdf/image-worker.ts +31 -0
  93. package/src/services/pdf/image.ts +109 -0
  94. package/src/services/pdf/index.ts +9 -0
  95. package/src/services/settings/api.ts +11 -6
  96. package/src/services/settings/app.ts +5 -4
  97. package/src/services/settings/core-settings.ts +4 -3
  98. package/src/services/settings/index.ts +67 -43
  99. package/src/services/settings/namespace.ts +2 -1
  100. package/src/services/settings/store.ts +83 -11
  101. package/src/shared/ai-platform-prompt.ts +1 -1
  102. package/src/shared/app-presentation.ts +1 -0
  103. package/src/shared/help.ts +41 -0
  104. package/src/shared/index.ts +9 -1
  105. package/src/shared/markdown/extensions/guided-help.ts +208 -64
  106. package/src/shared/markdown/formula.ts +8 -8
  107. package/src/shared/markdown/index.ts +72 -11
  108. package/src/shared/redirect.ts +12 -0
  109. package/src/ssr/CloudShell.island.tsx +86 -9
  110. package/src/ssr/Layout.tsx +62 -11
  111. package/src/ssr/LayoutHeader.tsx +15 -9
  112. package/src/ssr/LayoutHelp.browser-harness.tsx +130 -27
  113. package/src/ssr/LayoutHelp.tsx +569 -410
  114. package/src/ssr/LayoutRail.tsx +2 -2
  115. package/src/ssr/LayoutTabBar.tsx +29 -0
  116. package/src/ssr/PanelLayout.tsx +27 -0
  117. package/src/ssr/PanelRuntime.island.tsx +87 -0
  118. package/src/ssr/PanelSlot.tsx +108 -0
  119. package/src/ssr/PanelTrigger.tsx +49 -0
  120. package/src/ssr/ShellRuntime.island.tsx +72 -25
  121. package/src/ssr/app-appearance.ts +3 -2
  122. package/src/ssr/app-color.ts +100 -0
  123. package/src/ssr/app-launchpad.tsx +18 -1
  124. package/src/ssr/app-navigation.ts +15 -0
  125. package/src/ssr/compact-shell.baseline.json +5270 -0
  126. package/src/ssr/compact-shell.browser-harness.ts +431 -0
  127. package/src/ssr/help-messages.ts +60 -38
  128. package/src/ssr/help.ts +3 -0
  129. package/src/ssr/index.ts +3 -0
  130. package/src/ssr/layout-help-article.ts +41 -0
  131. package/src/ssr/layout-help-navigation.ts +0 -12
  132. package/src/ssr/layout-help-registry.tsx +4 -11
  133. package/src/ssr/layout-help-search.ts +9 -12
  134. package/src/ssr/layout-help-start.ts +68 -0
  135. package/src/ssr/panel-slot.browser-harness.tsx +110 -0
  136. package/src/ssr/platform-messages.ts +34 -0
  137. package/src/ssr/rail-navigation.ts +44 -0
  138. package/src/ssr/shell-data.tsx +11 -0
  139. package/src/ssr/shell-shortcuts.ts +19 -0
  140. package/src/styles/effects.css +689 -185
  141. package/src/styles/global.css +269 -55
  142. package/src/styles/panel.css +373 -0
  143. package/src/styles/resource-search.css +25 -0
  144. package/src/styles/tokens.css +23 -6
  145. package/src/styles/utilities-navigation.css +5 -21
  146. package/src/workflows/store/events.ts +24 -0
  147. package/src/workflows/store/index.ts +8 -0
  148. package/src/workflows/store/observability.ts +6 -1
  149. package/src/workflows/store/retention.ts +157 -0
@@ -1,30 +1,85 @@
1
- import { useLocale } from "@k2b/ui";
2
- import { createEffect } from "solid-js";
1
+ import { TabBar, useLocale } from "@k2b/ui";
2
+ import { createEffect, createMemo, createSignal, onCleanup, onMount, Show } from "solid-js";
3
+ import { isServer, Portal } from "solid-js/web";
3
4
  import { AppLaunchpadButton, setAppLaunchpadContext } from "./app-launchpad";
4
5
  import GlobalSearchTrigger from "./GlobalSearchTrigger";
5
6
  import LayoutBreadcrumbs from "./LayoutBreadcrumbs";
6
7
  import LayoutHelpTrigger from "./LayoutHelpTrigger";
7
8
  import LayoutPreferences from "./LayoutPreferences";
8
9
  import type { LayoutBreadcrumb } from "./layout-runtime";
10
+ import PanelTrigger from "./PanelTrigger";
9
11
  import ProfilePreferences from "./ProfilePreferences";
10
12
  import { platformMessages } from "./platform-messages";
11
13
  import RailApps from "./RailApps";
14
+ import { RAIL_PREFERENCES_EVENT, readRailContext } from "./rail-context";
15
+ import { phoneBarItems, projectRailNavigation } from "./rail-navigation";
12
16
  import ShellFocusToggle from "./ShellFocusToggle";
13
17
  import { useShellData } from "./shell-data";
14
18
 
15
19
  type CloudShellProps =
16
- | { slot: "rail"; currentPath: string }
17
- | { slot: "header"; authenticated: boolean; title?: string; launchpad: boolean; breadcrumbs?: LayoutBreadcrumb[] };
20
+ | { slot: "rail"; currentPath: string; panelOpen?: boolean }
21
+ | { slot: "header"; authenticated: boolean; title?: string; launchpad: boolean; breadcrumbs?: LayoutBreadcrumb[]; panelOpen?: boolean };
18
22
 
19
23
  /**
20
24
  * The interactive part of the shell chrome. `LayoutRail` and `LayoutHeader`
21
25
  * own the frame and the plain links around it as server markup.
22
26
  */
23
27
  export default function CloudShell(props: CloudShellProps) {
24
- return props.slot === "rail" ? <RailNavigation currentPath={props.currentPath} /> : <HeaderControls {...props} />;
28
+ return props.slot === "rail" ? (
29
+ <RailNavigation currentPath={props.currentPath} panelOpen={props.panelOpen} />
30
+ ) : (
31
+ <HeaderControls {...props} />
32
+ );
33
+ }
34
+
35
+ /**
36
+ * The phone bar's live copy, mounted with the rail's island so the shell adds no island instance. It renders nothing
37
+ * while the server's bar is current, so mounting moves and refocuses nothing. When the saved rail preferences or the
38
+ * URL (a fragment, Back and Forward, an in-page navigation) change what the bar shows, it renders the new bar into the
39
+ * bar's row, and CSS hides the server's.
40
+ */
41
+ function BarNavigation(props: { currentPath: string }) {
42
+ if (isServer) return null;
43
+ const data = useShellData();
44
+ const locale = useLocale();
45
+ const t = () => platformMessages.resolve([locale()]).t;
46
+ const initial = data?.rail;
47
+ if (!data || !initial) return null;
48
+ const [settings, setSettings] = createSignal(initial);
49
+ const [currentPath, setCurrentPath] = createSignal(props.currentPath);
50
+ const itemsFor = (rail: typeof initial, path: string) =>
51
+ phoneBarItems(projectRailNavigation(data.apps, rail, locale()), path, { label: t().home, path: data.home ?? "/" });
52
+ const served = JSON.stringify(itemsFor(initial, props.currentPath));
53
+ const items = createMemo(() => itemsFor(settings(), currentPath()));
54
+ onMount(() => {
55
+ const sync = () => setSettings(readRailContext()?.settings ?? initial);
56
+ const syncLocation = () => setCurrentPath(`${window.location.pathname}${window.location.search}${window.location.hash}`);
57
+ syncLocation();
58
+ window.addEventListener(RAIL_PREFERENCES_EVENT, sync);
59
+ window.addEventListener("popstate", syncLocation);
60
+ window.addEventListener("hashchange", syncLocation);
61
+ // In-page navigation (`@k2b/ssr/nav`) commits with pushState or replaceState, which only the Navigation API
62
+ // reports; where a browser lacks it, the bar follows at the next page load.
63
+ window.navigation?.addEventListener("currententrychange", syncLocation);
64
+ onCleanup(() => {
65
+ window.removeEventListener(RAIL_PREFERENCES_EVENT, sync);
66
+ window.removeEventListener("popstate", syncLocation);
67
+ window.removeEventListener("hashchange", syncLocation);
68
+ window.navigation?.removeEventListener("currententrychange", syncLocation);
69
+ });
70
+ });
71
+ const row = document.querySelector<HTMLElement>(".cloud-tab-bar__row");
72
+ if (!row) return null;
73
+ return (
74
+ <Show when={JSON.stringify(items()) !== served}>
75
+ <Portal mount={row}>
76
+ <TabBar variant="floating" class="cloud-tab-bar__live" label={t().apps} items={items()} />
77
+ </Portal>
78
+ </Show>
79
+ );
25
80
  }
26
81
 
27
- function RailNavigation(props: { currentPath: string }) {
82
+ function RailNavigation(props: { currentPath: string; panelOpen?: boolean }) {
28
83
  const data = useShellData();
29
84
  const locale = useLocale();
30
85
  const t = () => platformMessages.resolve([locale()]).t;
@@ -36,7 +91,9 @@ function RailNavigation(props: { currentPath: string }) {
36
91
  <ShellFocusToggle variant="rail" />
37
92
  <RailApps apps={data.apps} settings={data.rail} currentPath={props.currentPath} />
38
93
  <AppLaunchpadButton profile={profile} apps={data.apps} legalLinks={data.legalLinks} variant="rail" label={t().openApps} />
94
+ <BarNavigation currentPath={props.currentPath} />
39
95
  <div class="mt-auto shrink-0 flex flex-col items-center gap-1">
96
+ {(data.panel?.providers.length ?? 0) > 1 && <PanelTrigger variant="rail" open={props.panelOpen} />}
40
97
  <GlobalSearchTrigger variant="rail" />
41
98
  <LayoutHelpTrigger variant="rail" />
42
99
  <ProfilePreferences
@@ -51,7 +108,13 @@ function RailNavigation(props: { currentPath: string }) {
51
108
  }
52
109
 
53
110
  /** The header's title and controls, between the server-rendered Home and Sign in. */
54
- function HeaderControls(props: { authenticated: boolean; title?: string; launchpad: boolean; breadcrumbs?: LayoutBreadcrumb[] }) {
111
+ function HeaderControls(props: {
112
+ authenticated: boolean;
113
+ title?: string;
114
+ launchpad: boolean;
115
+ breadcrumbs?: LayoutBreadcrumb[];
116
+ panelOpen?: boolean;
117
+ }) {
55
118
  const data = useShellData();
56
119
  const locale = useLocale();
57
120
  const t = () => platformMessages.resolve([locale()]).t;
@@ -65,13 +128,27 @@ function HeaderControls(props: { authenticated: boolean; title?: string; launchp
65
128
  </div>
66
129
  )}
67
130
  <div class="flex min-w-0 items-center lg:hidden">
68
- <span class="truncate font-semibold">{props.title}</span>
131
+ <span class="layout-header-title truncate font-semibold">{props.title}</span>
132
+ {props.launchpad && (
133
+ <AppLaunchpadButton
134
+ profile={
135
+ data.profile
136
+ ? { name: data.profile.name, theme: data.theme, ...(data.profile.appHref ? { appHref: data.profile.appHref } : {}) }
137
+ : undefined
138
+ }
139
+ apps={data.apps}
140
+ legalLinks={data.legalLinks}
141
+ variant="title"
142
+ label={props.title}
143
+ />
144
+ )}
69
145
  </div>
70
146
  </div>
71
147
  <div class="flex shrink-0 items-center gap-1">
72
148
  <div class="flex items-center gap-1 lg:hidden">
73
- <LayoutHelpTrigger variant="header" />
149
+ {(data.panel?.providers.length ?? 0) > 1 && <PanelTrigger variant="header" open={props.panelOpen} />}
74
150
  {props.authenticated && <GlobalSearchTrigger variant="header" />}
151
+ <LayoutHelpTrigger variant="header" />
75
152
  {props.launchpad && (
76
153
  <AppLaunchpadButton
77
154
  profile={
@@ -1,7 +1,8 @@
1
1
  import type { DateContext } from "@k2b/stdlib";
2
- import { AppWorkspace, appWorkspaceLayoutStyle, LocaleProvider, NoticeCard, PageTrailProvider } from "@k2b/ui";
2
+ import { AppWorkspace, appWorkspaceLayoutStyle, keyboardPlatform, LocaleProvider, NoticeCard, PageTrailProvider } from "@k2b/ui";
3
3
  import type { JSX } from "solid-js/jsx-runtime";
4
4
  import { readAppWorkspaceLayoutCookie, resolveAppWorkspaceLayoutForSidebar } from "../_internal/app-workspace-state";
5
+ import { readPanelCookie } from "../_internal/panel-cookie";
5
6
  import { readShellFocusCookie } from "../_internal/shell-focus";
6
7
  import { resolveNavMatch } from "../contracts/app"; // ==========================
7
8
  import { isPwaShellAvailable } from "../contracts/pwa";
@@ -9,18 +10,21 @@ import { defaultRailPreferences } from "../contracts/rail-preferences";
9
10
  import { hasRole, type User } from "../contracts/shared";
10
11
  import { getLocale } from "../server/locale";
11
12
  import { getDateConfig } from "../server/time";
12
- import { dates } from "../shared";
13
+ import { dates, resolveHomePath } from "../shared";
13
14
  import { readThemePreferenceFromCookieHeader } from "../shared/theme";
14
15
  import AnnouncementBanners from "./AnnouncementBanners";
15
16
  import { appAppearanceStyle, resolveCurrentApp } from "./app-appearance";
16
- import { resolveRuntimeRoute, visibleNavigationApps } from "./app-navigation";
17
- import { getLayoutHelp } from "./help";
17
+ import { resolveRuntimeRoute, visibleNavigationApps, visiblePanelProviders } from "./app-navigation";
18
+ import { getLayoutHelp, layoutHelpFailed } from "./help";
18
19
  import LayoutFooter from "./LayoutFooter";
19
20
  import LayoutHeader from "./LayoutHeader";
20
21
  import LayoutRail, { type LayoutAppLink } from "./LayoutRail";
22
+ import LayoutTabBar from "./LayoutTabBar";
21
23
  import type { LayoutContext } from "./layout-context";
24
+ import { HELP_VIEW_LIMIT } from "./layout-help-start";
22
25
  import type { LayoutBreadcrumb } from "./layout-runtime";
23
26
  import PageTrailRow from "./PageTrailRow";
27
+ import PanelSlot from "./PanelSlot";
24
28
  import { derivePageTrail } from "./page-trail";
25
29
  import { platformMessages } from "./platform-messages";
26
30
  import { sortRailApps } from "./rail-navigation";
@@ -36,10 +40,16 @@ type LayoutProps = {
36
40
  children: JSX.Element;
37
41
  c: LayoutContext;
38
42
  title?: string | Breadcrumb[];
43
+ /** Up to three ids of the current app's Help articles that fit this page; Help lists them first under "For this view". Unknown ids and articles the reader may not see are ignored. */
44
+ help?: readonly string[];
39
45
  fullPage?: boolean /** Keep the shell viewport-bound and suppress its footer. */;
40
46
  fullWidth?: boolean /** Delegate scrolling and clipping to the page's work surface. */;
47
+ /** Hide the panel slot on this page (sign-in, print, presentation, public pages). */
48
+ panel?: false;
41
49
  focusMode?: boolean /** Render only the app canvas and content for dedicated editors or pop-out windows. */;
42
50
  flushCanvas?: boolean /** Remove app-canvas spacing for edge-to-edge focus surfaces such as pop-out windows. */;
51
+ /** Final localized app-specific export or printing guidance, shown when no data-print-region exists. */
52
+ printHint?: string;
43
53
  workspaceSidebarCollapsible?: boolean /** Resolve persisted sidebar geometry for the page's sidebar policy during SSR. */;
44
54
  }; // ==========================
45
55
  // Helpers
@@ -150,6 +160,12 @@ export default function Layout(props: LayoutProps) {
150
160
  const lang = getLocale(c);
151
161
  const t = platformMessages.resolve([lang]).t;
152
162
  const user = c.get("user");
163
+ const providers = user && props.panel !== false ? visiblePanelProviders(runtime.apps, user, lang) : [];
164
+ const panelState = readPanelCookie(
165
+ providers.length ? cookie : undefined,
166
+ providers.map((provider) => provider.id),
167
+ );
168
+ const panel = providers.length ? { providers, provider: panelState.provider ?? providers[0]?.id } : undefined;
153
169
  const pathname = new URL(c.req.raw.url).pathname;
154
170
  const currentRoute = resolveRuntimeRoute(runtime.apps, pathname);
155
171
  const currentApp = resolveCurrentApp(runtime.apps, pathname);
@@ -202,14 +218,29 @@ export default function Layout(props: LayoutProps) {
202
218
  // keeps short and long pages at the same width where scrollbars take space.
203
219
  const mainLayoutClass = fullPage || fullWidth ? "flex flex-col" : "md:overflow-auto md:[scrollbar-gutter:stable]";
204
220
  const canvasStyle =
205
- [appAppearanceStyle(currentApp?.appearance), appWorkspaceLayoutStyle(workspaceLayout), focusMode && flushCanvas ? "padding:0" : ""]
221
+ [
222
+ appAppearanceStyle(currentApp?.appearance),
223
+ appWorkspaceLayoutStyle(workspaceLayout),
224
+ panelState.widthRem !== undefined ? `--cloud-panel-width-set:${panelState.widthRem}rem` : "",
225
+ focusMode && flushCanvas ? "padding:0" : "",
226
+ ]
206
227
  .filter(Boolean)
207
228
  .join(";") || undefined;
208
229
  const appLabel = currentApp?.name ?? (pathname.startsWith("/admin") ? t.admin : undefined);
230
+ const helpIds = new Set(registeredHelp?.documents.map((topic) => topic.id));
231
+ const viewHelp = [...new Set(props.help)].filter((id) => helpIds.has(id)).slice(0, HELP_VIEW_LIMIT);
209
232
  const shellData: ShellData = {
233
+ ...(panel ? { panel } : {}),
234
+ platform: keyboardPlatform(c.req.raw.headers.get("User-Agent") ?? ""),
235
+ ...(viewHelp.length ? { help: viewHelp } : {}),
236
+ ...(layoutHelpFailed(c) ? { helpFailed: true as const } : {}),
210
237
  apps: allApps,
211
238
  ...(user
212
- ? { rail: c.get("railPreferences") ?? defaultRailPreferences(), profile: { name: profileName, avatarSrc: profileAvatarSrc, appHref } }
239
+ ? {
240
+ rail: c.get("railPreferences") ?? defaultRailPreferences(),
241
+ home: resolveHomePath(settings?.app?.home_path),
242
+ profile: { name: profileName, avatarSrc: profileAvatarSrc, appHref },
243
+ }
213
244
  : {}),
214
245
  legalLinks,
215
246
  theme,
@@ -221,20 +252,31 @@ export default function Layout(props: LayoutProps) {
221
252
  return (
222
253
  <LocaleProvider locale={lang}>
223
254
  <div
224
- class="cloud-app-canvas flex h-dvh w-full overflow-hidden"
255
+ class="cloud-app-canvas relative flex h-dvh w-full overflow-hidden"
225
256
  style={canvasStyle}
257
+ data-panel-transient=""
226
258
  data-app-id={currentApp?.id}
227
259
  data-app-base={currentRoute?.prefix}
228
260
  data-page-trail-title-only=""
229
261
  data-layout-full-page="true"
230
262
  data-workspace-sidebar-collapsed={workspaceLayout?.sidebarCollapsed ? "true" : undefined}
231
263
  >
232
- <ShellRuntime pageTrail help={runtimeHelp} />
264
+ <ShellRuntime pageTrail help={runtimeHelp} panel={panel ? true : undefined} />
233
265
  <PageTrailProvider trail={pageTrail}>
234
- <main class="min-h-0 min-w-0 flex-1">
266
+ <main class="min-h-0 min-w-0 flex-1" data-cloud-app-area="">
235
267
  <AppWorkspace.LayoutStateProvider state={workspaceLayout}>{props.children}</AppWorkspace.LayoutStateProvider>
236
268
  </main>
237
269
  </PageTrailProvider>
270
+ {/* Focus pages carry shell data only for their panel providers. */}
271
+ {panel && (
272
+ <ShellDataProvider data={{ panel, platform: shellData.platform, theme, apps: [], legalLinks: [], searchLinks: {} }}>
273
+ <PanelSlot panel={panel} state={{ ...panelState, open: false }} platform={shellData.platform} />
274
+ </ShellDataProvider>
275
+ )}
276
+ <div data-print-fallback="">
277
+ <p>{t.printFallbackTitle}</p>
278
+ <p>{props.printHint ?? t.printFallbackHint}</p>
279
+ </div>
238
280
  </div>
239
281
  </LocaleProvider>
240
282
  );
@@ -247,6 +289,7 @@ export default function Layout(props: LayoutProps) {
247
289
  <div
248
290
  class={`cloud-app-canvas relative flex w-full ${fullPage ? "h-dvh overflow-hidden" : "min-h-dvh md:h-dvh md:overflow-hidden"}`}
249
291
  style={canvasStyle}
292
+ data-panel={panelState.open ? "open" : undefined}
250
293
  data-app-id={currentApp?.id}
251
294
  data-app-base={currentRoute?.prefix}
252
295
  data-page-trail-title-only={titleOnly ? "" : undefined}
@@ -256,6 +299,7 @@ export default function Layout(props: LayoutProps) {
256
299
  data-workspace-sidebar-collapsed={workspaceLayout?.sidebarCollapsed ? "true" : undefined}
257
300
  >
258
301
  <ShellRuntime
302
+ panel={panel ? true : undefined}
259
303
  workspace={{ appId: currentApp?.id }}
260
304
  pageTrail
261
305
  focus={showRail}
@@ -270,11 +314,12 @@ export default function Layout(props: LayoutProps) {
270
314
  banners={user && announcements?.banners.length ? true : undefined}
271
315
  />
272
316
  <ShellDataProvider data={shellData}>
273
- {showRail && <LayoutRail currentPath={requestPath(c)} />}
317
+ {showRail && <LayoutRail currentPath={requestPath(c)} panelOpen={panel ? panelState.open : undefined} />}
274
318
  {showRail && <ShellFocusToggle variant="float" />}
275
319
  {showRail && <div class="cloud-shell-reveal" data-cloud-shell-reveal="" aria-hidden="true" />}
276
- <div class="layout-shell-content flex min-h-0 min-w-0 flex-1 flex-col">
320
+ <div class="layout-shell-content flex min-h-0 min-w-0 flex-1 flex-col" data-cloud-app-area="">
277
321
  <LayoutHeader
322
+ panelOpen={panel ? panelState.open : undefined}
278
323
  authenticated={showRail}
279
324
  title={appLabel ?? breadcrumbs.at(-1)?.title}
280
325
  launchpad={!!(user || appLabel)}
@@ -291,7 +336,13 @@ export default function Layout(props: LayoutProps) {
291
336
  </PageTrailProvider>
292
337
  {!fullPage && !showRail && <LayoutFooter appName={settings?.app?.copyright || appName} legalLinks={legalLinks} />}
293
338
  </div>
339
+ {panel && <PanelSlot panel={panel} state={panelState} platform={shellData.platform} />}
340
+ {showRail && <LayoutTabBar currentPath={requestPath(c)} />}
294
341
  </ShellDataProvider>
342
+ <div data-print-fallback="">
343
+ <p>{t.printFallbackTitle}</p>
344
+ <p>{props.printHint ?? t.printFallbackHint}</p>
345
+ </div>
295
346
  </div>
296
347
  </LocaleProvider>
297
348
  );
@@ -5,6 +5,7 @@ import { platformMessages } from "./platform-messages";
5
5
 
6
6
  type LayoutHeaderProps = {
7
7
  authenticated: boolean;
8
+ panelOpen?: boolean;
8
9
  title?: string;
9
10
  launchpad: boolean;
10
11
  breadcrumbs?: LayoutBreadcrumb[];
@@ -14,10 +15,12 @@ type LayoutHeaderProps = {
14
15
  * Phones (below `md`) always get this header. From `md` it remains only on
15
16
  * anonymous pages; signed-in people reach Home, search, help, the app grid,
16
17
  * and their profile through the rail, so the app area starts at the top edge.
17
- * Anonymous pages have no rail: up to `lg` their header keeps the app name,
18
- * help, search, and the app grid, and from `lg` it shows the breadcrumbs.
19
- * Home and Sign in stay server markup, so focus on them survives the shell's
20
- * mount; the shell renders the title and the controls between them.
18
+ * On a signed-in phone it is the title row on the app gradient: Home moves to
19
+ * the bottom bar's Start, so the row holds the title, search, help, and the
20
+ * app menu. Anonymous pages have no rail: up to `lg` their header keeps Home,
21
+ * the app name, help, search, and the app grid, and from `lg` it shows the
22
+ * breadcrumbs. Home and Sign in stay server markup, so focus on them survives
23
+ * the shell's mount; the shell renders the title and the controls between them.
21
24
  */
22
25
  export default function LayoutHeader(props: LayoutHeaderProps) {
23
26
  const locale = useLocale();
@@ -29,13 +32,16 @@ export default function LayoutHeader(props: LayoutHeaderProps) {
29
32
  >
30
33
  {/* A long title must not squeeze the button; `shrink-0` sits on a wrapper because the button's own
31
34
  styles would win over a utility on the link. */}
32
- <div class="flex shrink-0">
33
- <IconButtonLink href="/" label={t().home}>
34
- <img src="/branding/logo" alt="" class={props.authenticated ? "h-4 w-4" : "h-6 w-6"} />
35
- </IconButtonLink>
36
- </div>
35
+ {!props.authenticated && (
36
+ <div class="flex shrink-0">
37
+ <IconButtonLink href="/" label={t().home}>
38
+ <img src="/branding/logo" alt="" class="h-6 w-6" />
39
+ </IconButtonLink>
40
+ </div>
41
+ )}
37
42
  <CloudShell
38
43
  slot="header"
44
+ panelOpen={props.panelOpen}
39
45
  authenticated={props.authenticated}
40
46
  title={props.title}
41
47
  launchpad={props.launchpad}
@@ -1,43 +1,146 @@
1
- import { LocaleProvider } from "@k2b/ui";
1
+ import { AppWorkspace, LocaleProvider } from "@k2b/ui";
2
2
  import { render } from "solid-js/web";
3
+ import { registerContextAwareCommand } from "../browser/command-bridge";
3
4
  import type { HelpDocumentManifest, HelpDocumentPayload } from "../shared/help";
4
- import { LayoutHelpPage } from "./LayoutHelp";
5
+ import { LayoutHelpPage, openLayoutHelpDialog } from "./LayoutHelp";
6
+ import { registry } from "./layout-help-registry";
5
7
 
6
- const documents: HelpDocumentManifest[] = Array.from({ length: 8 }, (_, index) => ({
7
- id: `topic-${index}`,
8
- title: `Topic ${index}`,
8
+ const params = new URL(location.href).searchParams;
9
+ const fixture = (id: string, title: string, order: number, kind?: HelpDocumentManifest["kind"], icon?: string): HelpDocumentManifest => ({
10
+ id,
11
+ title,
9
12
  description: "Help browser fixture",
10
- order: index,
11
- url: `/api/help/topic-${index}`,
13
+ order,
14
+ ...(kind ? { kind } : {}),
15
+ ...(icon ? { icon } : {}),
16
+ url: `/api/help/${id}`,
12
17
  searchUrl: "/api/help/search",
13
- }));
14
- const html = Array.from(
15
- { length: 8 },
16
- (_, index) =>
17
- `<h2 id="section-${index}" class="help-section-title" data-help-icon="ti ti-point">Section ${index}</h2>${Array.from({ length: 18 }, () => "<p>Read this guidance to finish the task and understand how the app works.</p>").join("")}`,
18
- ).join("");
18
+ });
19
+ // `?start`: an app with every kind and more tasks than the start shows as tiles.
20
+ const startDocuments: HelpDocumentManifest[] = [
21
+ fixture("share-space", "Share a Space", 10, "task", "ti ti-share"),
22
+ fixture("claim-task", "Claim a task", 20, "task", "ti ti-hand-grab"),
23
+ fixture("statuses", "Create and order statuses", 30, "task", "ti ti-arrows-sort"),
24
+ fixture("calendar-feed", "See dates in your own calendar", 40, "task", "ti ti-calendar-share"),
25
+ fixture("templates", "Start from a template", 50, undefined, "ti ti-template"),
26
+ fixture("filters", "Filter and find items", 60, "task", "ti ti-filter"),
27
+ fixture("handoffs", "Hand work over", 70, "task", "ti ti-arrows-exchange"),
28
+ fixture("archive", "Archive a Space", 80, "task", "ti ti-archive"),
29
+ fixture("import", "Import tasks from a spreadsheet with a very long title that wraps", 90, "task", "ti ti-table-import"),
30
+ fixture("recurring", "Repeat a task", 100, "task", "ti ti-repeat"),
31
+ fixture("start", "Start with Spaces", 5, "start", "ti ti-rocket"),
32
+ fixture("access-levels", "Access levels", 110, "reference", "ti ti-key"),
33
+ fixture("troubleshoot", "Fix problems in Spaces", 120, "troubleshoot", "ti ti-lifebuoy"),
34
+ ];
35
+ const documents: HelpDocumentManifest[] = params.has("start")
36
+ ? startDocuments
37
+ : Array.from({ length: 8 }, (_, index) => fixture(`topic-${index}`, `Topic ${index}`, index));
38
+ // `?commands`: what the shell and an app register on a real page.
39
+ if (params.has("commands")) {
40
+ for (const command of [
41
+ { id: "cloud.shell.focus", title: "Hide sidebar", description: "", shortcut: "mod+\\" },
42
+ { id: "cloud.search", title: "Open global search", description: "", shortcut: "mod+k" },
43
+ { id: "cloud.help", title: "Open shortcut help", description: "", shortcut: "shift+/" },
44
+ { id: "spaces.new", title: "New task", description: "", shortcut: "n" },
45
+ { id: "spaces.filter", title: "Filter tasks", description: "", shortcut: "f" },
46
+ { id: "spaces.complete", title: "Complete selected", description: "", shortcut: "x", scope: "selection" as const },
47
+ ])
48
+ registerContextAwareCommand({ ...command, action: () => {} });
49
+ }
50
+ const lead = "<p>Share a Space so that other people can see and change its tasks.</p>";
51
+ const longHtml =
52
+ lead +
53
+ Array.from(
54
+ { length: 8 },
55
+ (_, index) =>
56
+ `<h2 id="section-${index}" class="help-section-title">Section ${index}</h2>${Array.from({ length: 18 }, () => "<p>Read this guidance to finish the task and understand how the app works.</p>").join("")}`,
57
+ ).join("");
58
+ // A short task article: the short answer, one section, four steps.
59
+ const taskHtml = `${lead}<h2 id="invite-people" class="help-section-title">Invite people</h2><div class="help-steps"><ol class="k2b-steps"><li>Open <span class="k2b-label-chip">Space settings</span>.</li><li>Choose <span class="k2b-label-chip">Add person</span>.</li><li>Enter a name or a group.</li><li>Choose the access level.</li></ol></div><p>Spaces saves the change at once.</p><div class="help-compare"><ul><li><strong>Add a person</strong> Gives one named person access to every task in this Space.</li><li><strong>Add a group</strong> Gives everyone in the group access, including people who join later.</li></ul></div>`;
60
+ const link = (id: string, title: string, appId = "spaces", appName = "Spaces") => ({
61
+ appId,
62
+ appName,
63
+ accent: appId === "spaces" ? "#16a34a" : "#2563eb",
64
+ documentId: id,
65
+ title,
66
+ description: "Help browser fixture",
67
+ url: `/help/apps/${appId}/${id}`,
68
+ });
19
69
  window.fetch = Object.assign(
20
70
  async (input: URL | RequestInfo) => {
21
71
  await new Promise((resolve) => setTimeout(resolve, 80));
22
72
  const path = String(input);
23
- if (path.includes("/search")) return Response.json({ locale: "en", ids: ["topic-0", "topic-1"] });
73
+ if (path.includes("/search")) {
74
+ const query = new URL(path, location.href).searchParams.get("q") ?? "";
75
+ const ids = params.has("start")
76
+ ? documents.filter((document) => document.title.toLocaleLowerCase().includes(query)).map((document) => document.id)
77
+ : ["topic-0", "topic-1"];
78
+ return Response.json({ locale: "en", ids });
79
+ }
24
80
  const id = path.split("/").at(-1) ?? "topic-0";
25
- const payload: HelpDocumentPayload = { id, locale: "en", title: id, markdown: "Help", html };
81
+ const payload: HelpDocumentPayload = {
82
+ id,
83
+ locale: "en",
84
+ title: id,
85
+ markdown: "Help",
86
+ html: id === "topic-1" ? taskHtml : longHtml,
87
+ kind: "task",
88
+ needs: "manage",
89
+ next: link("topic-2", "Topic 2"),
90
+ related: [link("topic-3", "Topic 3"), link("mail-share", "Share a mailbox", "mail", "Mail")],
91
+ };
26
92
  return Response.json(payload);
27
93
  },
28
94
  { preconnect: () => {} },
29
95
  );
30
96
  const main = document.querySelector<HTMLElement>(".layout-content-main");
31
97
  if (!main) throw new Error("Missing Layout main");
32
- const embedded = new URL(location.href).searchParams.has("embedded");
33
- const host = document.createElement("div");
34
- if (embedded) host.style.cssText = "width:360px;height:700px";
35
- main.append(host);
36
- render(
37
- () => (
38
- <LocaleProvider locale="en">
39
- <LayoutHelpPage documents={documents} embedded={embedded} pageBase="/app/spaces/help" />
40
- </LocaleProvider>
41
- ),
42
- host,
43
- );
98
+ const embedded = params.has("embedded");
99
+ const referenceHost = params.get("host");
100
+ if (referenceHost === "grids" || referenceHost === "pulse") {
101
+ // The Grids and Pulse query reference windows: one article beside the reference sidebar, without the Cloud shell.
102
+ const host = document.createElement("div");
103
+ host.className = "h-dvh";
104
+ document.body.replaceChildren(host);
105
+ const reader = () => <LayoutHelpPage documents={documents.slice(0, 1)} initialTopic="topic-0" includeShortcuts={false} embedded />;
106
+ render(
107
+ () => (
108
+ <LocaleProvider locale="en">
109
+ <AppWorkspace>
110
+ <AppWorkspace.Sidebar>
111
+ <AppWorkspace.SidebarDesktop>
112
+ <AppWorkspace.SidebarBody>Reference</AppWorkspace.SidebarBody>
113
+ </AppWorkspace.SidebarDesktop>
114
+ </AppWorkspace.Sidebar>
115
+ <AppWorkspace.Content>
116
+ {referenceHost === "grids" ? (
117
+ <AppWorkspace.Main class="p-[var(--ui-space-shell)]" scroll={false}>
118
+ <div class="flex min-h-0 flex-1 flex-col overflow-auto">{reader()}</div>
119
+ </AppWorkspace.Main>
120
+ ) : (
121
+ <AppWorkspace.Main class="overflow-y-auto">{reader()}</AppWorkspace.Main>
122
+ )}
123
+ </AppWorkspace.Content>
124
+ </AppWorkspace>
125
+ </LocaleProvider>
126
+ ),
127
+ host,
128
+ );
129
+ } else if (params.has("modal")) {
130
+ // The dialog reads the topics the page registered, as Layout.HelpDocuments does.
131
+ for (const manifest of documents) registry()?.set(manifest.id, manifest);
132
+ window.__cloudLayoutHelpPageBase = "/app/spaces/help";
133
+ openLayoutHelpDialog();
134
+ } else {
135
+ const host = document.createElement("div");
136
+ if (embedded) host.style.cssText = "width:360px;height:700px";
137
+ main.append(host);
138
+ render(
139
+ () => (
140
+ <LocaleProvider locale="en">
141
+ <LayoutHelpPage documents={documents} embedded={embedded} pageBase="/app/spaces/help" />
142
+ </LocaleProvider>
143
+ ),
144
+ host,
145
+ );
146
+ }