@k2b/cloud 0.30.0 → 0.31.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 (76) hide show
  1. package/package.json +3 -2
  2. package/src/_internal/app-presentation.ts +22 -1
  3. package/src/_internal/capabilities.ts +2 -2
  4. package/src/_internal/define-app.ts +14 -4
  5. package/src/_internal/registry.ts +50 -6
  6. package/src/_internal/widget-declarations.ts +53 -0
  7. package/src/ai/approval-routes.ts +40 -4
  8. package/src/ai/approvals.ts +68 -10
  9. package/src/ai/chat/blocks.tsx +53 -32
  10. package/src/ai/chat/live-turn.browser-harness.tsx +4 -1
  11. package/src/ai/chat/message-actions.tsx +3 -1
  12. package/src/ai/chat/messages.ts +18 -0
  13. package/src/ai/chat/tool-groups.ts +66 -12
  14. package/src/ai/chat/tool-names.ts +4 -2
  15. package/src/ai/chat/turn-layout.ts +8 -0
  16. package/src/ai/chat/turn-view.tsx +94 -25
  17. package/src/ai/chat/web-tools.tsx +95 -1
  18. package/src/ai/client/controller.ts +6 -3
  19. package/src/ai/code-runtime-tools.ts +50 -17
  20. package/src/ai/code-source-contracts.ts +25 -21
  21. package/src/ai/code-source-tools.ts +3 -1
  22. package/src/ai/executor.ts +170 -14
  23. package/src/ai/fetch-file-tool.ts +41 -4
  24. package/src/ai/firecrawl-tools.ts +87 -4
  25. package/src/ai/index.ts +8 -0
  26. package/src/ai/migrate.ts +113 -2
  27. package/src/ai/protocol.ts +22 -1
  28. package/src/ai/routes.ts +11 -0
  29. package/src/ai/runtime.ts +46 -7
  30. package/src/ai/store.ts +66 -1
  31. package/src/ai/tool-audit.ts +11 -1
  32. package/src/ai/tools.ts +91 -3
  33. package/src/ai/types.ts +39 -0
  34. package/src/ai/web-provenance.ts +103 -0
  35. package/src/api/capabilities.ts +3 -2
  36. package/src/api/widgets.ts +36 -5
  37. package/src/browser/CloudResourceSearch.tsx +1 -1
  38. package/src/browser/command-shortcuts.ts +21 -6
  39. package/src/browser/spotlight-position.ts +4 -1
  40. package/src/browser/widgets.ts +7 -5
  41. package/src/capabilities/executions.ts +3 -0
  42. package/src/cli/commands.ts +20 -0
  43. package/src/cli/index.ts +16 -0
  44. package/src/cli/plugin.ts +30 -0
  45. package/src/contracts/app.ts +34 -2
  46. package/src/contracts/registry.ts +9 -1
  47. package/src/contracts/widgets.ts +47 -2
  48. package/src/desktop/solid.tsx +6 -7
  49. package/src/server/index.ts +1 -0
  50. package/src/server/widget-request.ts +27 -0
  51. package/src/services/approvals/index.ts +225 -0
  52. package/src/services/approvals/migrate.ts +33 -0
  53. package/src/shared/app-presentation.ts +11 -0
  54. package/src/shared/email-html.ts +43 -8
  55. package/src/ssr/AdminLayout.tsx +4 -7
  56. package/src/ssr/AppLaunchpad.island.tsx +1 -1
  57. package/src/ssr/Layout.tsx +38 -14
  58. package/src/ssr/LayoutFooter.tsx +1 -1
  59. package/src/ssr/LayoutHeader.tsx +17 -20
  60. package/src/ssr/LayoutHelp.tsx +1 -1
  61. package/src/ssr/LayoutRail.tsx +1 -2
  62. package/src/ssr/PageTrail.tsx +23 -0
  63. package/src/ssr/PageTrailRow.island.tsx +20 -0
  64. package/src/ssr/PageTrailRuntime.island.tsx +26 -0
  65. package/src/ssr/ProfilePreferences.island.tsx +7 -3
  66. package/src/ssr/islands/index.ts +1 -0
  67. package/src/ssr/layout-runtime.ts +60 -0
  68. package/src/ssr/mobile-menu-history.ts +1 -1
  69. package/src/ssr/page-trail-runtime.ts +313 -0
  70. package/src/ssr/page-trail.browser-harness.tsx +216 -0
  71. package/src/ssr/page-trail.ts +59 -0
  72. package/src/ssr/platform-messages.ts +4 -0
  73. package/src/styles/effects.css +21 -4
  74. package/src/styles/global.css +35 -2
  75. package/src/styles/utilities-layout.css +24 -1
  76. package/src/styles/utilities-navigation.css +6 -18
@@ -33,13 +33,10 @@ export default function AdminLayout(props: Props) {
33
33
  const url = new URL(c.req.raw.url);
34
34
  const currentPath = `${url.pathname}${url.search}`;
35
35
  const runtime = getLocalizedRuntimeContext(c);
36
- const breadcrumbs: Breadcrumb[] = [
37
- { title: t.start, href: "/" },
38
- { title: t.admin, href: "/admin" },
39
- ];
40
- if (title !== "Overview" && title !== t.overview) {
41
- breadcrumbs.push({ title });
42
- }
36
+ // Admin is the area's root, as an app's root is: the rail and the admin sidebar lead there. So it is no level
37
+ // above an admin page, and every admin page shows its title alone, whichever app owns it.
38
+ const overview = title === "Overview" || title === t.overview;
39
+ const breadcrumbs: Breadcrumb[] = [{ title: t.start, href: "/" }, { title: overview ? t.admin : title }];
43
40
  return (
44
41
  <Layout c={c} fullWidth fullPage title={breadcrumbs}>
45
42
  <AppWorkspace mobileSurface="flush" class="min-h-0 flex-1" resizable={false}>
@@ -60,7 +60,7 @@ export function openAppLaunchpad(apps?: AppLaunchpadApp[], legalLinks?: AppLaunc
60
60
  const context = apps ? { apps, legalLinks: legalLinks ?? [], profile } : (window.__cloudAppLaunchpad ?? readEmbeddedContext());
61
61
  if (!context) return;
62
62
  window.__cloudAppLaunchpad = context;
63
- if (window.matchMedia("(max-width: 1023px)").matches) {
63
+ if (window.matchMedia("(max-width: 767px)").matches) {
64
64
  void openCloudMobileMenu(context);
65
65
  return;
66
66
  }
@@ -1,5 +1,5 @@
1
1
  import type { DateContext } from "@k2b/stdlib";
2
- import { AppWorkspace, appWorkspaceLayoutStyle, LocaleProvider, NoticeCard } from "@k2b/ui";
2
+ import { AppWorkspace, appWorkspaceLayoutStyle, 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
5
  import { navigationSearchItems } from "../browser/navigation-search";
@@ -13,8 +13,8 @@ import { dates } from "../shared";
13
13
  import { readThemeFromCookieHeader } from "../shared/theme";
14
14
  import type { AppLaunchpadApp } from "./AppLaunchpad.island";
15
15
  import AppWorkspaceController from "./AppWorkspaceController.island";
16
- import { appAppearanceStyle, resolveCurrentApp } from "./app-appearance";
17
- import { visibleNavigationApps } from "./app-navigation";
16
+ import { appAppearanceStyle } from "./app-appearance";
17
+ import { resolveRuntimeRoute, visibleNavigationApps } from "./app-navigation";
18
18
  import BrowserPushRegistration from "./BrowserPushRegistration.island";
19
19
  import GlobalAnnouncements from "./GlobalAnnouncements.island";
20
20
  import type { GlobalSearchHelpApp } from "./GlobalSearchHelpDialog";
@@ -24,6 +24,9 @@ import LayoutHeader from "./LayoutHeader";
24
24
  import LayoutRail, { type LayoutAppLink } from "./LayoutRail";
25
25
  import type { LayoutContext } from "./layout-context";
26
26
  import type { LayoutBreadcrumb } from "./layout-runtime";
27
+ import PageTrailRow from "./PageTrailRow.island";
28
+ import PageTrailRuntime from "./PageTrailRuntime.island";
29
+ import { derivePageTrail } from "./page-trail";
27
30
  import { platformMessages } from "./platform-messages";
28
31
  import RegisteredHelpDocuments from "./RegisteredHelpDocuments.island";
29
32
  import { sortRailApps } from "./rail-navigation";
@@ -153,7 +156,8 @@ export default function Layout(props: LayoutProps) {
153
156
  const t = platformMessages.resolve([lang]).t;
154
157
  const user = c.get("user");
155
158
  const pathname = new URL(c.req.raw.url).pathname;
156
- const currentApp = resolveCurrentApp(runtime.apps, pathname);
159
+ const currentRoute = resolveRuntimeRoute(runtime.apps, pathname);
160
+ const currentApp = currentRoute?.app;
157
161
  const registeredHelp = getLayoutHelp(c);
158
162
  const workspaceLayout = resolveAppWorkspaceLayoutForSidebar(
159
163
  readAppWorkspaceLayoutCookie(cookie, currentApp?.id),
@@ -201,6 +205,13 @@ export default function Layout(props: LayoutProps) {
201
205
  const pageTitle = typeof title === "string" ? title : (title?.at(-1)?.title ?? appName);
202
206
  if (!page.title) page.title = pageTitle;
203
207
  const breadcrumbs: Breadcrumb[] = !title ? [{ title: appName }] : typeof title === "string" ? [{ title }] : title;
208
+ // The way up is derived from the title array by one rule (see `derivePageTrail`). Anonymous pages keep their
209
+ // header breadcrumbs, and focus surfaces such as pop-out editors have no way up; both show the title alone. The
210
+ // canvas carries this for `layout.update()`.
211
+ const titleOnly = !user || focusMode;
212
+ const pageTrail = titleOnly
213
+ ? { title: pageTitle, ancestors: [] }
214
+ : derivePageTrail(title ?? appName, currentRoute?.prefix, appName, `${pathname}${new URL(c.req.raw.url).search}`);
204
215
  const showRail = !!user;
205
216
  // The profile menu offers the mobile app only while the installation runs it.
206
217
  const appHref = user && isPwaShellAvailable(runtime.apps) ? "/me/app" : undefined;
@@ -209,9 +220,9 @@ export default function Layout(props: LayoutProps) {
209
220
  user?.avatarHash && user.id
210
221
  ? `/api/accounts/users/${encodeURIComponent(user.id)}/avatar?rev=${encodeURIComponent(user.avatarHash)}`
211
222
  : undefined;
212
- // From lg, `main` is the page scroller of regular pages. Its stable gutter
223
+ // From md, `main` is the page scroller of regular pages. Its stable gutter
213
224
  // keeps short and long pages at the same width where scrollbars take space.
214
- const mainLayoutClass = fullPage || fullWidth ? "flex flex-col" : "lg:overflow-auto lg:[scrollbar-gutter:stable]";
225
+ const mainLayoutClass = fullPage || fullWidth ? "flex flex-col" : "md:overflow-auto md:[scrollbar-gutter:stable]";
215
226
  const canvasStyle =
216
227
  [appAppearanceStyle(currentApp?.appearance), appWorkspaceLayoutStyle(workspaceLayout), focusMode && flushCanvas ? "padding:0" : ""]
217
228
  .filter(Boolean)
@@ -223,24 +234,34 @@ export default function Layout(props: LayoutProps) {
223
234
  class="cloud-app-canvas flex h-dvh w-full overflow-hidden"
224
235
  style={canvasStyle}
225
236
  data-app-id={currentApp?.id}
237
+ data-app-base={currentRoute?.prefix}
238
+ data-page-trail-title-only=""
226
239
  data-layout-full-page="true"
227
240
  data-workspace-sidebar-collapsed={workspaceLayout?.sidebarCollapsed ? "true" : undefined}
228
241
  >
229
242
  <TimezoneCookie />
230
243
  {registeredHelp && <RegisteredHelpDocuments documents={registeredHelp.documents} pageBase={registeredHelp.pageBase} />}
231
- <main class="min-h-0 min-w-0 flex-1">
232
- <AppWorkspace.LayoutStateProvider state={workspaceLayout}>{props.children}</AppWorkspace.LayoutStateProvider>
233
- </main>
244
+ <PageTrailProvider trail={pageTrail}>
245
+ <PageTrailRuntime />
246
+ <main class="min-h-0 min-w-0 flex-1">
247
+ <AppWorkspace.LayoutStateProvider state={workspaceLayout}>{props.children}</AppWorkspace.LayoutStateProvider>
248
+ </main>
249
+ </PageTrailProvider>
234
250
  </div>
235
251
  </LocaleProvider>
236
252
  );
237
253
  }
254
+ // The canvas takes the window's visible height. `vh` follows the large
255
+ // viewport, which is taller than the window while a browser shows its
256
+ // toolbar, and would let a viewport-bound page scroll by that difference.
238
257
  return (
239
258
  <LocaleProvider locale={lang}>
240
259
  <div
241
- class={`cloud-app-canvas relative flex w-full ${fullPage ? "h-dvh overflow-hidden" : "min-h-screen lg:h-screen lg:overflow-hidden"}`}
260
+ class={`cloud-app-canvas relative flex w-full ${fullPage ? "h-dvh overflow-hidden" : "min-h-dvh md:h-dvh md:overflow-hidden"}`}
242
261
  style={canvasStyle}
243
262
  data-app-id={currentApp?.id}
263
+ data-app-base={currentRoute?.prefix}
264
+ data-page-trail-title-only={titleOnly ? "" : undefined}
244
265
  data-layout-full-page={fullPage ? "true" : undefined}
245
266
  data-layout-authenticated={showRail ? "true" : undefined}
246
267
  data-workspace-sidebar-collapsed={workspaceLayout?.sidebarCollapsed ? "true" : undefined}
@@ -279,7 +300,6 @@ export default function Layout(props: LayoutProps) {
279
300
  launchpadApps={launchpadApps}
280
301
  legalLinks={legalLinks}
281
302
  openAppsLabel={t.openApps}
282
- profileAvatarSrc={profileAvatarSrc}
283
303
  profileName={profileName}
284
304
  searchHelpApps={searchHelpApps}
285
305
  searchLinks={searchLinks}
@@ -296,9 +316,13 @@ export default function Layout(props: LayoutProps) {
296
316
  )}
297
317
  {user && <ProfileWarnings user={user} locale={lang} />}
298
318
  {user && <ExpiryWarnings user={user} dateConfig={dateConfig} locale={lang} />}
299
- <main class={`layout-content-main min-h-0 min-w-0 flex-1 ${mainLayoutClass}`}>
300
- <AppWorkspace.LayoutStateProvider state={workspaceLayout}>{props.children}</AppWorkspace.LayoutStateProvider>
301
- </main>
319
+ <PageTrailProvider trail={pageTrail}>
320
+ <PageTrailRuntime />
321
+ <PageTrailRow />
322
+ <main class={`layout-content-main min-h-0 min-w-0 flex-1 ${mainLayoutClass}`}>
323
+ <AppWorkspace.LayoutStateProvider state={workspaceLayout}>{props.children}</AppWorkspace.LayoutStateProvider>
324
+ </main>
325
+ </PageTrailProvider>
302
326
  {!fullPage && !showRail && <LayoutFooter appName={settings?.app?.copyright || appName} legalLinks={legalLinks} />}
303
327
  </div>
304
328
  </div>
@@ -5,7 +5,7 @@ type LayoutFooterProps = {
5
5
 
6
6
  export default function LayoutFooter(props: LayoutFooterProps) {
7
7
  return (
8
- <footer class="hidden shrink-0 flex-wrap items-center justify-center gap-x-4 gap-y-1 px-3 py-2 text-xs text-dimmed lg:flex">
8
+ <footer class="hidden shrink-0 flex-wrap items-center justify-center gap-x-4 gap-y-1 px-3 py-2 text-xs text-dimmed md:flex">
9
9
  {props.legalLinks.map((link) => (
10
10
  <a href={link.href} class="flex items-center gap-1 transition-colors hover:text-primary">
11
11
  {link.icon && <i class={`${link.icon} text-xs`} />}
@@ -8,7 +8,6 @@ import LayoutBreadcrumbs from "./LayoutBreadcrumbs.island";
8
8
  import LayoutHelpTrigger from "./LayoutHelpTrigger.island";
9
9
  import LayoutPreferences from "./LayoutPreferences.island";
10
10
  import type { LayoutBreadcrumb } from "./layout-runtime";
11
- import ProfilePreferences from "./ProfilePreferences.island";
12
11
 
13
12
  type LayoutHeaderProps = {
14
13
  accent?: string;
@@ -21,7 +20,6 @@ type LayoutHeaderProps = {
21
20
  launchpadApps: AppLaunchpadApp[];
22
21
  legalLinks: Array<{ label: string; href: string; icon?: string }>;
23
22
  openAppsLabel: string;
24
- profileAvatarSrc?: string;
25
23
  profileName: string;
26
24
  searchHelpApps: GlobalSearchHelpApp[];
27
25
  searchLinks?: NavigationSearchItem[];
@@ -29,23 +27,32 @@ type LayoutHeaderProps = {
29
27
  theme: CloudTheme;
30
28
  };
31
29
 
30
+ /**
31
+ * Phones (below `md`) always get this header. From `md` it remains only on
32
+ * anonymous pages; signed-in people reach Home, search, help, the app grid,
33
+ * and their profile through the rail, so the app area starts at the top edge.
34
+ * Anonymous pages have no rail: up to `lg` their header keeps the app name,
35
+ * help, search, and the app grid, and from `lg` it shows the breadcrumbs.
36
+ */
32
37
  export default function LayoutHeader(props: LayoutHeaderProps) {
33
38
  return (
34
39
  <header
35
- class="layout-header paper flex min-h-[2.875rem] shrink-0 items-center justify-between px-2 py-1.5 lg:px-3 lg:py-2"
40
+ class={`layout-header paper flex min-h-[2.875rem] shrink-0 items-center justify-between px-2 py-1.5 md:px-3 md:py-2 ${props.authenticated ? "md:hidden" : ""}`}
36
41
  style="box-shadow: var(--ui-shadow-surface)"
37
42
  >
38
43
  <div class="flex min-w-0 items-center gap-2">
39
- {/* Signed-in desktops reach Home through the rail. `lg:hidden` sits on a wrapper because the button's
40
- own display would win over the utility on the link. */}
41
- <div class={props.authenticated ? "flex shrink-0 lg:hidden" : "flex shrink-0"}>
44
+ {/* A long title must not squeeze the button; `shrink-0` sits on a wrapper because the button's own
45
+ styles would win over a utility on the link. */}
46
+ <div class="flex shrink-0">
42
47
  <IconButtonLink href="/" label={props.homeLabel}>
43
48
  <img src="/branding/logo" alt="" class={props.authenticated ? "h-4 w-4" : "h-6 w-6"} />
44
49
  </IconButtonLink>
45
50
  </div>
46
- <div class="hidden min-w-0 items-center lg:flex">
47
- <LayoutBreadcrumbs breadcrumbs={props.breadcrumbs} />
48
- </div>
51
+ {!props.authenticated && (
52
+ <div class="hidden min-w-0 items-center lg:flex">
53
+ <LayoutBreadcrumbs breadcrumbs={props.breadcrumbs} />
54
+ </div>
55
+ )}
49
56
  <div class="flex min-w-0 items-center lg:hidden">
50
57
  <span class="truncate font-semibold">{props.appLabel ?? props.breadcrumbs.at(-1)?.title}</span>
51
58
  </div>
@@ -78,17 +85,7 @@ export default function LayoutHeader(props: LayoutHeaderProps) {
78
85
  />
79
86
  )}
80
87
  </div>
81
- {props.authenticated ? (
82
- <div class="hidden lg:block">
83
- <ProfilePreferences
84
- appHref={props.appHref}
85
- avatarSrc={props.profileAvatarSrc}
86
- initialTheme={props.theme}
87
- name={props.profileName}
88
- placement="header"
89
- />
90
- </div>
91
- ) : (
88
+ {!props.authenticated && (
92
89
  <>
93
90
  <LayoutPreferences initialTheme={props.theme} position="bottom-left" />
94
91
  <ButtonLink href="/auth/login" size="sm" variant="primary">
@@ -534,7 +534,7 @@ const HelpShell = (props: {
534
534
  props.surface === "modal"
535
535
  ? "h-[min(48rem,var(--ui-dialog-available-height))] overflow-hidden panel-dialog-shell [box-shadow:var(--ui-shadow-float)]"
536
536
  : props.surface === "page"
537
- ? "min-h-screen"
537
+ ? "min-h-dvh"
538
538
  : "h-full"
539
539
  }`}
540
540
  style={appAccentStyle(props.accent)}
@@ -43,7 +43,7 @@ export default function LayoutRail(props: LayoutRailProps) {
43
43
  <script id="cloud-rail-data" type="application/json">
44
44
  {jsonScript({ apps: props.apps, settings: props.railSettings })}
45
45
  </script>
46
- <aside class="layout-rail hidden w-10 shrink-0 flex-col lg:flex">
46
+ <aside class="layout-rail hidden w-10 shrink-0 flex-col md:flex">
47
47
  <div class="layout-rail-logo flex h-[2.875rem] shrink-0 items-center justify-center">
48
48
  <a href="/" aria-label={props.homeLabel}>
49
49
  <img src="/branding/logo" alt="Logo" class="h-5 w-5" />
@@ -66,7 +66,6 @@ export default function LayoutRail(props: LayoutRailProps) {
66
66
  avatarSrc={props.profileAvatarSrc}
67
67
  initialTheme={props.theme}
68
68
  name={props.profileName}
69
- placement="rail"
70
69
  />
71
70
  </div>
72
71
  </nav>
@@ -0,0 +1,23 @@
1
+ import { PageTitle, usePageTrail } from "@k2b/ui";
2
+ import { type JSX, Show } from "solid-js";
3
+
4
+ export type PageTrailProps = {
5
+ /** Quiet text after the title, for example a count. */
6
+ subtitle?: JSX.Element;
7
+ /** Defaults to `"h1"`. */
8
+ as?: "h1" | "h2" | "p";
9
+ class?: string;
10
+ };
11
+
12
+ /**
13
+ * The page's title with its way up, for an app that draws its own header.
14
+ * It shows what the shell would show, from the same title array (`Layout`'s
15
+ * `title`, kept current by `layout.update()`), and takes the shell's place:
16
+ * workspace heads and the shell's row step back while it is on the page.
17
+ */
18
+ export default function PageTrail(props: PageTrailProps) {
19
+ const trail = usePageTrail();
20
+ return (
21
+ <Show when={trail()}>{(current) => <PageTitle trail={current()} subtitle={props.subtitle} as={props.as} class={props.class} />}</Show>
22
+ );
23
+ }
@@ -0,0 +1,20 @@
1
+ import { PageTitle, usePageTrail } from "@k2b/ui";
2
+ import { Show } from "solid-js";
3
+
4
+ /**
5
+ * The shell's own place for the way up, above pages without a workspace. It
6
+ * shows only on a page with a page above it, and steps back when the page
7
+ * renders a workspace (whose head shows it) or its own `PageTrail`.
8
+ */
9
+ export default function PageTrailRow() {
10
+ const trail = usePageTrail();
11
+ const deep = () => {
12
+ const current = trail();
13
+ return current?.ancestors.length ? current : undefined;
14
+ };
15
+ return (
16
+ <div class="layout-page-head" data-trail={deep() ? "up" : undefined}>
17
+ <Show when={deep()}>{(current) => <PageTitle trail={current()} as="p" fallback />}</Show>
18
+ </div>
19
+ );
20
+ }
@@ -0,0 +1,26 @@
1
+ import { followPageTrail, installPageTitleController, useLocale } from "@k2b/ui";
2
+ import { onCleanup, onMount } from "solid-js";
3
+ import { installPageTrailRuntime } from "./page-trail-runtime";
4
+ import { platformMessages } from "./platform-messages";
5
+
6
+ /**
7
+ * One owner per document for the way up: it collapses every page title path
8
+ * to its space, keeps server-rendered workspace heads current after
9
+ * `layout.update()`, runs the per-tab memory behind up links, and offers
10
+ * "One level up" (Alt+↑) while the page has a page above it.
11
+ */
12
+ export default function PageTrailRuntime() {
13
+ const locale = useLocale();
14
+ onMount(() => {
15
+ const t = platformMessages.resolve([locale()]).t;
16
+ const disposeTitles = installPageTitleController(document);
17
+ const disposeFollow = followPageTrail(document);
18
+ const disposeRuntime = installPageTrailRuntime((page) => ({ title: t.pageUp({ page }), description: t.pageUpDescription }));
19
+ onCleanup(() => {
20
+ disposeTitles();
21
+ disposeFollow();
22
+ disposeRuntime();
23
+ });
24
+ });
25
+ return null;
26
+ }
@@ -8,16 +8,20 @@ type ProfilePreferencesProps = {
8
8
  avatarSrc?: string;
9
9
  initialTheme: CloudTheme;
10
10
  name: string;
11
- placement: "header" | "rail";
12
11
  };
13
12
 
13
+ /**
14
+ * The avatar at the bottom of the rail. Its menu opens beside the rail and,
15
+ * because the avatar is the rail's last control, grows upward from the window's
16
+ * bottom edge.
17
+ */
14
18
  export default function ProfilePreferences(props: ProfilePreferencesProps) {
15
19
  const { preferences, items, signOut, signingOut } = createProfileActions(props.initialTheme, props.appHref);
16
20
 
17
21
  const avatar = () => <Avatar name={props.name} src={props.avatarSrc} size="xs" />;
18
22
 
19
23
  return (
20
- <div class="layout-profile-preferences" data-placement={props.placement}>
24
+ <div class="layout-profile-preferences">
21
25
  <button type="button" class="layout-profile-preferences__link" aria-label={preferences.messages().menuLabel}>
22
26
  {avatar()}
23
27
  </button>
@@ -49,7 +53,7 @@ export default function ProfilePreferences(props: ProfilePreferencesProps) {
49
53
  class="layout-profile-preferences__dropdown"
50
54
  items={items()}
51
55
  label={preferences.messages().menuLabel}
52
- position={props.placement === "rail" ? "right-start" : "bottom-left"}
56
+ position="right-start"
53
57
  width="14rem"
54
58
  >
55
59
  <Dropdown.Trigger
@@ -1,3 +1,4 @@
1
+ export { default as PageTrail, type PageTrailProps } from "../PageTrail";
1
2
  export { default as WorkspaceNavigationProvider } from "../WorkspaceNavigationProvider";
2
3
  export { provideWorkspaceNavigation } from "../workspace-navigation";
3
4
  export { default as SearchBar } from "./SearchBar";
@@ -1,3 +1,6 @@
1
+ import { readPageTrail, setPageTrail } from "@k2b/ui";
2
+ import { derivePageTrail, dropSelfLinks } from "./page-trail";
3
+
1
4
  export type LayoutBreadcrumb = {
2
5
  title: string;
3
6
  href?: string;
@@ -10,13 +13,70 @@ export type LayoutUpdate = {
10
13
 
11
14
  export const LAYOUT_UPDATE_EVENT = "cloud:layout:update";
12
15
 
16
+ /** The canvas attribute that carries the current app's base path, for the trail rule in the browser. */
17
+ export const APP_BASE_ATTRIBUTE = "data-app-base";
18
+ /** Set on the canvas of a page that shows its title without a way up: anonymous and focus-mode pages. */
19
+ const TITLE_ONLY_ATTRIBUTE = "data-page-trail-title-only";
20
+
21
+ /**
22
+ * Window event before the way up (a trail link or Alt+↑) loads a page. Its
23
+ * `detail.href` is where it leads: the page above, as this tab left it.
24
+ * Cancelling it keeps the document; the listener then navigates itself.
25
+ */
26
+ export const PAGE_UP_EVENT = "cloud:page-up";
27
+ export type PageUpDetail = { href: string };
28
+
29
+ const here = () => `${location.pathname}${location.search}`;
30
+
13
31
  export const layout = {
32
+ /**
33
+ * Updates the page after an in-page navigation: the document title, and the
34
+ * page title with its way up wherever the shell or `PageTrail` shows it.
35
+ * `breadcrumbs` is the same title array `Layout` takes, under the same rule;
36
+ * `title` alone renames the current page and keeps the levels above it.
37
+ */
14
38
  update(update: LayoutUpdate) {
15
39
  if (typeof window === "undefined") return;
16
40
 
17
41
  const title = update.title ?? update.breadcrumbs?.at(-1)?.title;
18
42
  if (title) document.title = title;
43
+ const canvas = document.querySelector(".cloud-app-canvas");
44
+ const base = canvas?.getAttribute(APP_BASE_ATTRIBUTE) ?? undefined;
45
+ const current = readPageTrail();
46
+ const derived = update.breadcrumbs?.length
47
+ ? derivePageTrail(update.breadcrumbs, base, title ?? current?.title ?? document.title)
48
+ : title && current
49
+ ? { ...current, title }
50
+ : undefined;
51
+ if (!derived) return;
52
+ const trail = update.title ? { ...derived, title: update.title } : derived;
53
+ setPageTrail(canvas?.hasAttribute(TITLE_ONLY_ATTRIBUTE) ? { ...trail, ancestors: [] } : trail);
54
+ if (update.breadcrumbs?.length) {
55
+ // An island may change the address right after this call, in the same task, so the link to the page itself
56
+ // is known only then. Comparing earlier would drop the page the island has just left.
57
+ queueMicrotask(() => {
58
+ const published = readPageTrail();
59
+ if (!published) return;
60
+ const settled = dropSelfLinks(published, here());
61
+ if (settled !== published) setPageTrail(settled);
62
+ });
63
+ }
19
64
 
20
65
  window.dispatchEvent(new CustomEvent<LayoutUpdate>(LAYOUT_UPDATE_EVENT, { detail: update }));
21
66
  },
67
+
68
+ /**
69
+ * Lets an island that navigates in place, such as a file browser, take the
70
+ * way up itself instead of a page load that would end its running work.
71
+ * `go` gets the destination and returns `true` when it goes there in place.
72
+ * Returns the disposer.
73
+ */
74
+ handleUp(go: (href: string) => boolean): () => void {
75
+ if (typeof window === "undefined") return () => {};
76
+ const listener = (event: Event) => {
77
+ if (!event.defaultPrevented && go((event as CustomEvent<PageUpDetail>).detail.href)) event.preventDefault();
78
+ };
79
+ window.addEventListener(PAGE_UP_EVENT, listener);
80
+ return () => window.removeEventListener(PAGE_UP_EVENT, listener);
81
+ },
22
82
  };
@@ -18,7 +18,7 @@ export function openMobileMenu(view: (beforeSelect: () => Promise<boolean>) => D
18
18
  history.pushState({ ...previous, [marker]: id }, "");
19
19
  const abort = new AbortController();
20
20
  let leavingPage = false;
21
- const mobile = window.matchMedia("(max-width: 1023px)");
21
+ const mobile = window.matchMedia("(max-width: 767px)");
22
22
  const pop = () => {
23
23
  if (history.state?.[marker] !== id) abort.abort();
24
24
  };