@k2b/cloud 0.39.0 → 0.40.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 (194) hide show
  1. package/README.md +8 -0
  2. package/package.json +8 -4
  3. package/src/_internal/app-favicon.ts +4 -2
  4. package/src/_internal/capabilities.ts +72 -48
  5. package/src/_internal/capability-reviews.ts +81 -0
  6. package/src/_internal/capability-schemas.ts +29 -0
  7. package/src/_internal/capability-sentences.ts +216 -1
  8. package/src/_internal/capability-streams.ts +7 -0
  9. package/src/_internal/define-app.ts +36 -15
  10. package/src/_internal/font-preloads.ts +2 -1
  11. package/src/_internal/runtime-watcher.ts +6 -0
  12. package/src/_internal/static-assets.ts +52 -3
  13. package/src/_internal/status-preserving-ssr.ts +2 -0
  14. package/src/access/PermissionEditor.tsx +2 -2
  15. package/src/account/EntitySearch.tsx +1 -1
  16. package/src/ai/action-review.ts +101 -0
  17. package/src/ai/background-review.ts +72 -0
  18. package/src/ai/capabilities.ts +29 -11
  19. package/src/ai/capability-execution.ts +76 -43
  20. package/src/ai/capability-result.ts +95 -0
  21. package/src/ai/chat/action-card.ts +398 -0
  22. package/src/ai/chat/action-messages.ts +129 -0
  23. package/src/ai/chat/action-question.browser-harness.tsx +245 -0
  24. package/src/ai/chat/action-question.fixtures.ts +733 -0
  25. package/src/ai/chat/action-question.tsx +669 -0
  26. package/src/ai/chat/blocks.tsx +19 -25
  27. package/src/ai/chat/message-actions.tsx +6 -0
  28. package/src/ai/chat/tool-action.tsx +481 -0
  29. package/src/ai/chat/turn-view.tsx +10 -160
  30. package/src/ai/chat/view-block.tsx +15 -5
  31. package/src/ai/chat-task-contracts.ts +4 -1
  32. package/src/ai/chat-task-reasons-migrate.ts +7 -0
  33. package/src/ai/chat-task-reasons.ts +1 -1
  34. package/src/ai/chat-tasks.ts +55 -25
  35. package/src/ai/client/controller.ts +59 -0
  36. package/src/ai/default-tools.ts +9 -1
  37. package/src/ai/executor.ts +190 -39
  38. package/src/ai/index.ts +2 -0
  39. package/src/ai/migrate.ts +11 -2
  40. package/src/ai/projects-routes.ts +3 -0
  41. package/src/ai/protocol.ts +14 -2
  42. package/src/ai/recent-resources.ts +240 -0
  43. package/src/ai/recent-sent-refs.ts +38 -0
  44. package/src/ai/routes.ts +145 -1
  45. package/src/ai/runtime.ts +1 -0
  46. package/src/ai/skills-routes.ts +3 -0
  47. package/src/ai/skills.ts +10 -1
  48. package/src/ai/store.ts +227 -24
  49. package/src/ai/tool-audit.ts +8 -0
  50. package/src/ai/tool-wording.ts +24 -2
  51. package/src/ai/types.ts +28 -3
  52. package/src/api/accounts-entities.ts +2 -1
  53. package/src/api/admin-ai-projects.ts +2 -0
  54. package/src/api/admin-ai-skills.ts +2 -0
  55. package/src/api/admin-ai-usage.ts +2 -0
  56. package/src/api/admin-geoip.ts +80 -0
  57. package/src/api/admin-identity.ts +3 -0
  58. package/src/api/admin-lifecycle.ts +3 -0
  59. package/src/api/admin-outgoing-mail.ts +2 -0
  60. package/src/api/app-approval.ts +46 -5
  61. package/src/api/approvals-loader.ts +23 -1
  62. package/src/api/approvals.ts +2 -0
  63. package/src/api/auth.ts +25 -6
  64. package/src/api/capabilities.ts +44 -11
  65. package/src/api/capability-streams.ts +12 -0
  66. package/src/api/help.ts +4 -28
  67. package/src/api/index.ts +6 -1
  68. package/src/api/lasting-access-messages.ts +17 -0
  69. package/src/api/mcp.ts +3 -0
  70. package/src/api/me-panel-consent.ts +71 -0
  71. package/src/api/me.ts +80 -33
  72. package/src/api/search.ts +3 -1
  73. package/src/api/sync-ops.ts +2 -0
  74. package/src/api/widgets.ts +3 -0
  75. package/src/approvals/Approvals.tsx +180 -238
  76. package/src/approvals/messages.ts +12 -24
  77. package/src/browser/CloudResourceSearch.browser-harness.tsx +10 -2
  78. package/src/browser/app-approval.ts +54 -5
  79. package/src/browser/commands.ts +22 -6
  80. package/src/browser/composer-catalog.ts +45 -3
  81. package/src/browser/composer-insert.ts +10 -0
  82. package/src/browser/composer-open-messages.ts +17 -0
  83. package/src/browser/composer-open.ts +110 -0
  84. package/src/browser/discoverable-command.ts +6 -0
  85. package/src/browser/hints.ts +6 -0
  86. package/src/browser/live-connection.ts +26 -2
  87. package/src/browser/navigation-apps.ts +7 -0
  88. package/src/browser/panel-context-bridge.ts +30 -0
  89. package/src/browser/panel-context-receiver.ts +53 -0
  90. package/src/browser/panel-menu.tsx +56 -0
  91. package/src/browser/panel-messages.ts +11 -0
  92. package/src/browser/panel-shortcuts.ts +31 -0
  93. package/src/browser/panel-slot.ts +412 -10
  94. package/src/browser/panel.ts +37 -0
  95. package/src/browser/search-commands.ts +6 -3
  96. package/src/capabilities/client.ts +8 -2
  97. package/src/capabilities/reviews.ts +205 -0
  98. package/src/capabilities/server.ts +10 -3
  99. package/src/capabilities/testing.ts +47 -0
  100. package/src/capabilities/types.ts +2 -2
  101. package/src/cli/account.ts +13 -3
  102. package/src/cli/admin/geoip.ts +115 -0
  103. package/src/cli/admin/index.ts +3 -0
  104. package/src/contracts/app-approval.ts +120 -13
  105. package/src/contracts/approvals.ts +2 -2
  106. package/src/contracts/capabilities.ts +410 -18
  107. package/src/contracts/geoip.ts +42 -0
  108. package/src/contracts/index.ts +3 -0
  109. package/src/contracts/panel-context-plain.ts +99 -0
  110. package/src/contracts/panel-context.ts +93 -0
  111. package/src/contracts/seen-hints.ts +35 -0
  112. package/src/server/client-ip.ts +5 -0
  113. package/src/server/help-payload.ts +38 -0
  114. package/src/server/middleware/auth.ts +11 -1
  115. package/src/server/middleware/middleware.ts +9 -0
  116. package/src/server/middleware/no-transform.ts +19 -0
  117. package/src/server/middleware/rate-limit.ts +4 -6
  118. package/src/server/request-context/address.ts +45 -0
  119. package/src/server/request-context/geoip.ts +380 -0
  120. package/src/server/request-context/index.ts +33 -0
  121. package/src/server/request-context/user-agent.ts +95 -0
  122. package/src/services/app-approval.ts +382 -59
  123. package/src/services/approvals/device.ts +7 -5
  124. package/src/services/approvals/index.ts +6 -5
  125. package/src/services/approvals/list.ts +26 -5
  126. package/src/services/auth-flows/notification-sender.ts +3 -0
  127. package/src/services/geoip/index.ts +164 -0
  128. package/src/services/geoip/store.ts +30 -0
  129. package/src/services/geoip/upload.ts +150 -0
  130. package/src/services/identity/routes.ts +5 -1
  131. package/src/services/index.ts +11 -0
  132. package/src/services/known-browsers.ts +312 -0
  133. package/src/services/login-address-store.ts +51 -0
  134. package/src/services/login-context.ts +118 -0
  135. package/src/services/mandates/index.ts +3 -0
  136. package/src/services/mandates/policy.ts +15 -3
  137. package/src/services/notifications/email-frame.ts +1 -1
  138. package/src/services/oauth-tokens.ts +16 -2
  139. package/src/services/panel-context-consent.ts +58 -0
  140. package/src/services/service-account-credentials.ts +21 -0
  141. package/src/services/session/index.ts +97 -16
  142. package/src/services/session/recent.ts +13 -0
  143. package/src/services/webauthn.ts +12 -0
  144. package/src/shared/email-html.ts +2 -0
  145. package/src/shared/help.ts +3 -0
  146. package/src/shared-text/cursor.ts +22 -0
  147. package/src/shared-text/documents.ts +142 -0
  148. package/src/shared-text/events.ts +43 -0
  149. package/src/shared-text/index.ts +12 -0
  150. package/src/shared-text/log.ts +48 -0
  151. package/src/shared-text/text-diff.ts +23 -0
  152. package/src/shared-text/types.ts +41 -0
  153. package/src/ssr/AdminLayout.tsx +2 -0
  154. package/src/ssr/AppLaunchpadPanel.tsx +2 -2
  155. package/src/ssr/CloudShell.island.tsx +10 -2
  156. package/src/ssr/Layout.tsx +25 -2
  157. package/src/ssr/LayoutHelp.browser-harness.tsx +2 -0
  158. package/src/ssr/LayoutHelp.tsx +435 -274
  159. package/src/ssr/LayoutHelpPanel.browser-harness.tsx +136 -0
  160. package/src/ssr/LayoutHelpTrigger.tsx +27 -2
  161. package/src/ssr/MobileNavigation.tsx +2 -2
  162. package/src/ssr/PanelLayout.tsx +9 -1
  163. package/src/ssr/PanelRuntime.island.tsx +7 -0
  164. package/src/ssr/PanelSlot.tsx +53 -3
  165. package/src/ssr/ShellRuntime.island.tsx +11 -2
  166. package/src/ssr/app-color.ts +14 -9
  167. package/src/ssr/app-navigation.ts +16 -3
  168. package/src/ssr/compact-shell.browser-harness.ts +30 -22
  169. package/src/ssr/dialog-app-colors.browser-harness.tsx +43 -0
  170. package/src/ssr/help-messages.ts +4 -0
  171. package/src/ssr/help.ts +20 -0
  172. package/src/ssr/hints.ts +25 -0
  173. package/src/ssr/index.ts +2 -0
  174. package/src/ssr/islands/index.ts +4 -3
  175. package/src/ssr/layout-context.ts +2 -0
  176. package/src/ssr/layout-help-article.server.ts +50 -0
  177. package/src/ssr/layout-help-start.ts +33 -7
  178. package/src/ssr/panel-consent.ts +29 -0
  179. package/src/ssr/panel-context.ts +10 -0
  180. package/src/ssr/panel-slot.browser-harness.tsx +53 -4
  181. package/src/ssr/platform-messages.ts +18 -0
  182. package/src/ssr/shell-data.tsx +2 -1
  183. package/src/styles/effects.css +198 -9
  184. package/src/styles/file-chooser.css +2 -1
  185. package/src/styles/global.css +49 -12
  186. package/src/styles/panel.css +47 -4
  187. package/src/styles/resource-search.css +6 -2
  188. package/src/styles/tokens.css +17 -11
  189. package/src/styles/utilities-buttons.css +3 -2
  190. package/src/styles/utilities-data.css +2 -1
  191. package/src/styles/utilities-feedback.css +2 -2
  192. package/src/styles/utilities-layout.css +3 -13
  193. package/src/styles/utilities-markdown-editor.css +6 -6
  194. package/src/styles/utilities-navigation.css +17 -22
@@ -0,0 +1,136 @@
1
+ import { keyboardPlatform } from "@k2b/ui";
2
+ import { Hono } from "hono";
3
+ import { defineApp } from "../_internal/define-app";
4
+ import { person } from "../_internal/define-app.test-fixture";
5
+ import type { AppAppearance, CloudRuntime } from "../contracts/app";
6
+ import type { User } from "../contracts/shared";
7
+ import type { HelpDocumentManifest, HelpDocumentPayload } from "../shared/help";
8
+ import { appAppearanceStyle } from "./app-appearance";
9
+ import Layout from "./Layout";
10
+ import { LayoutHelpPage, type LayoutHelpPageProps, LayoutHelpPanel, type LayoutHelpPanelProps } from "./LayoutHelp";
11
+ import { helpArticlePartsServer } from "./layout-help-article.server";
12
+ import PanelLayout from "./PanelLayout";
13
+ import { readPanelContext } from "./panel-context";
14
+
15
+ export const documents: HelpDocumentManifest[] = [
16
+ { id: "spaces-share", title: "Share a Space", kind: "task", icon: "ti ti-share" },
17
+ { id: "spaces-start", title: "Start with Spaces", kind: "start", icon: "ti ti-rocket" },
18
+ ].map((topic) => ({
19
+ ...topic,
20
+ kind: topic.id === "spaces-share" ? "task" : "start",
21
+ order: 1,
22
+ url: `/api/help/${topic.id}`,
23
+ searchUrl: "/api/help/search",
24
+ }));
25
+ export const helpManifest = { documents, pageBase: "/help/apps/spaces", baseLocale: "en", locale: "en", manifestHash: "fixture" };
26
+ export const fixtureDocument: HelpDocumentPayload = {
27
+ id: "spaces-share",
28
+ title: "Share a Space",
29
+ locale: "en",
30
+ markdown: "Share",
31
+ kind: "task",
32
+ needs: "manage",
33
+ related: [],
34
+ // A lead, the facts chips, and a section: everything the article head reads from the article.
35
+ html: '<p>Share a Space so other people can see and edit its tasks.</p><h2 id="share">Share</h2><div class="help-steps"><ol><li>Open Space settings.</li><li>Add a person.</li></ol></div>',
36
+ };
37
+ const initialArticle = { payload: fixtureDocument, parts: helpArticlePartsServer(fixtureDocument.html) };
38
+ const appearance: AppAppearance = { accent: "#16a34a", background: { from: "#16a34a", to: "#0284c7" } };
39
+ const apps: CloudRuntime["apps"] = [
40
+ {
41
+ id: "spaces",
42
+ name: "Spaces",
43
+ icon: "ti ti-box",
44
+ description: "Help fixture",
45
+ routes: ["/app/spaces"],
46
+ nav: { href: "/app/spaces", section: "primary" },
47
+ appearance,
48
+ help: helpManifest,
49
+ },
50
+ {
51
+ id: "core",
52
+ name: "Core",
53
+ icon: "ti ti-cloud",
54
+ description: "Core fixture",
55
+ routes: ["/"],
56
+ panel: { href: "/panel", label: "Help", icon: "ti ti-help-circle" },
57
+ },
58
+ ];
59
+ const app = defineApp({
60
+ id: "help-fixture",
61
+ name: "Help fixture",
62
+ description: "Help fixture",
63
+ icon: "ti ti-help-circle",
64
+ baseUrl: "http://fixture:3000",
65
+ routes: ["/"],
66
+ });
67
+ type Environment = { Variables: { runtime: CloudRuntime; user: User } };
68
+ const PropsSnapshot = (props: { value: LayoutHelpPanelProps | LayoutHelpPageProps }) => (
69
+ <script id="help-fixture-props" type="application/json" innerHTML={JSON.stringify(props.value).replace(/</g, "\\u003c")} />
70
+ );
71
+ export const server = new Hono<Environment>()
72
+ .use(async (c, next) => {
73
+ c.set("runtime", { apps });
74
+ c.set("user", person);
75
+ await next();
76
+ })
77
+ .get(
78
+ "/app/spaces",
79
+ ...app.ssr<Environment>((c) => () => (
80
+ <Layout c={c} title="Spaces" help={["spaces-share"]}>
81
+ <p>App content</p>
82
+ </Layout>
83
+ )),
84
+ )
85
+ .get(
86
+ "/panel",
87
+ ...app.ssr<Environment>((c) => {
88
+ const context = readPanelContext(c);
89
+ const props: LayoutHelpPanelProps = {
90
+ documents,
91
+ app: { id: "spaces", name: "Spaces", icon: "ti ti-box" },
92
+ appearance: appAppearanceStyle(appearance),
93
+ view: context?.app === "spaces" ? context.help?.filter((id) => documents.some((topic) => topic.id === id)) : [],
94
+ platform: keyboardPlatform(c.req.header("User-Agent") ?? ""),
95
+ pageBase: helpManifest.pageBase,
96
+ initialArticle: c.req.query("topic") === "spaces-share" ? initialArticle : undefined,
97
+ initialTopic: c.req.query("topic") === "spaces-share" ? "spaces-share" : undefined,
98
+ };
99
+ return () => (
100
+ <PanelLayout c={c} title="Help">
101
+ <div id="help-fixture-root" class="flex h-full min-h-0 flex-1 flex-col">
102
+ <LayoutHelpPanel {...props} />
103
+ </div>
104
+ <PropsSnapshot value={props} />
105
+ </PanelLayout>
106
+ );
107
+ }),
108
+ )
109
+ .get(
110
+ "/help/apps/spaces/spaces-share",
111
+ ...app.ssr<Environment>((c) => () => (
112
+ <Layout c={c} title="Help">
113
+ <div id="help-fixture-root">
114
+ <LayoutHelpPage
115
+ documents={documents}
116
+ initialTopic="spaces-share"
117
+ initialArticle={initialArticle}
118
+ pageBase={helpManifest.pageBase}
119
+ />
120
+ </div>
121
+ <PropsSnapshot value={{ documents, initialTopic: "spaces-share", initialArticle, pageBase: helpManifest.pageBase }} />
122
+ </Layout>
123
+ )),
124
+ );
125
+
126
+ /**
127
+ * Capture during parsing and immediately after mounting; coordinates inside the provider are relative to its viewport.
128
+ * The article head keeps its position, width and height; the contents column keeps its position and width.
129
+ */
130
+ export function helpPanelGeometry() {
131
+ return ["[data-help-view]", "[data-cloud-app-area]", ".help-article-head", ".help-article-aside"].map((selector, index) => {
132
+ const rect = document.querySelector(selector)?.getBoundingClientRect();
133
+ if (!rect) return null;
134
+ return index < 3 ? { x: rect.x, y: rect.y, width: rect.width, height: rect.height } : { x: rect.x, y: rect.y, width: rect.width };
135
+ });
136
+ }
@@ -1,4 +1,7 @@
1
1
  import { IconButton, Tooltip, useLocale } from "@k2b/ui";
2
+ import { createSignal, onCleanup, onMount } from "solid-js";
3
+ import { isServer } from "solid-js/web";
4
+ import { closePanel, openPanel } from "../browser/panel";
2
5
  import { importOnDemand } from "../browser/reload";
3
6
  import { createBusyAction } from "./busy-action";
4
7
  import { platformMessages } from "./platform-messages";
@@ -6,6 +9,8 @@ import { platformMessages } from "./platform-messages";
6
9
  type LayoutHelpTriggerProps = {
7
10
  variant: "header" | "rail";
8
11
  class?: string;
12
+ panel?: boolean;
13
+ helpOpen?: boolean;
9
14
  };
10
15
 
11
16
  /** Opens end-user help from either the desktop rail or the compact header. */
@@ -13,17 +18,35 @@ export default function LayoutHelpTrigger(props: LayoutHelpTriggerProps) {
13
18
  const locale = useLocale();
14
19
  const t = () => platformMessages.resolve([locale()]).t;
15
20
  const action = createBusyAction();
16
- const openHelp = () =>
17
- action.run(
21
+ // The server knows the cookie; the browser also knows the mode: a phone starts with the sheet closed, whatever the
22
+ // cookie says, and the lazy controller may have changed the state before this island mounts.
23
+ const readOpen = () => {
24
+ const canvas = document.querySelector(".cloud-app-canvas");
25
+ const attribute = window.matchMedia("(min-width: 768px)").matches ? "data-panel" : "data-panel-sheet";
26
+ return canvas?.getAttribute(attribute) === "open" && document.getElementById("cloud-panel")?.dataset.provider === "core";
27
+ };
28
+ const [helpOpen, setHelpOpen] = createSignal(isServer ? !!props.helpOpen : !!props.panel && readOpen());
29
+ onMount(() => {
30
+ const changed = (event: WindowEventMap["cloud.panel.state"]) => setHelpOpen(event.detail.open && event.detail.provider === "core");
31
+ window.addEventListener("cloud.panel.state", changed);
32
+ onCleanup(() => window.removeEventListener("cloud.panel.state", changed));
33
+ });
34
+ const openHelp = () => {
35
+ if (helpOpen()) return closePanel();
36
+ if (openPanel("core")) return;
37
+ return action.run(
18
38
  () => importOnDemand(() => import("./LayoutHelp")),
19
39
  (help) => help.openLayoutHelpDialog(),
20
40
  );
41
+ };
21
42
 
22
43
  if (props.variant === "rail")
23
44
  return (
24
45
  <Tooltip.Trigger
25
46
  type="button"
26
47
  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 ?? ""}`}
48
+ aria-controls={props.panel ? "cloud-panel" : undefined}
49
+ aria-expanded={props.panel ? helpOpen() : undefined}
27
50
  onClick={openHelp}
28
51
  aria-busy={action.busy() ? "true" : undefined}
29
52
  aria-label={t().openHelp}
@@ -38,6 +61,8 @@ export default function LayoutHelpTrigger(props: LayoutHelpTriggerProps) {
38
61
  return (
39
62
  <IconButton
40
63
  class={props.class}
64
+ aria-controls={props.panel ? "cloud-panel" : undefined}
65
+ aria-expanded={props.panel ? helpOpen() : undefined}
41
66
  onClick={openHelp}
42
67
  aria-busy={action.busy() ? "true" : undefined}
43
68
  label={t().openHelp}
@@ -48,8 +48,8 @@ function MobileNavigation(props: {
48
48
  value={tab}
49
49
  onValueChange={setTab}
50
50
  options={[
51
- { value: "app", label: workspace()?.label || t().appMenu },
52
- { value: "apps", label: t().allApps },
51
+ { value: "app", label: <span class="block k2b-truncate-fade">{workspace()?.label || t().appMenu}</span> },
52
+ { value: "apps", label: <span class="block k2b-truncate-fade">{t().allApps}</span> },
53
53
  ]}
54
54
  />
55
55
  }
@@ -5,12 +5,19 @@ import { getLocale } from "../server/locale";
5
5
  import { resolveRuntimeRoute } from "./app-navigation";
6
6
  import type { MinimalLayoutContext } from "./layout-context";
7
7
  import PanelRuntime from "./PanelRuntime.island";
8
+ import { readPanelContext } from "./panel-context";
8
9
  import { getRuntimeContext } from "./runtime";
9
10
 
10
- export type PanelLayoutProps = { c: MinimalLayoutContext; title: string; children: JSX.Element };
11
+ export type PanelLayoutProps = {
12
+ c: MinimalLayoutContext & { header(name: string, value: string): void };
13
+ title: string;
14
+ children: JSX.Element;
15
+ };
11
16
 
12
17
  /** A provider page embedded in the shell's same-origin panel. */
13
18
  export default function PanelLayout(props: PanelLayoutProps) {
19
+ props.c.header("Referrer-Policy", "same-origin");
20
+ const context = readPanelContext(props.c);
14
21
  const runtime = getRuntimeContext(props.c);
15
22
  // The provider owns this path by the canonical prefixes its panel declaration was checked against.
16
23
  const apps = runtime.apps.map((entry) => ({ ...entry, routes: entry.routes.map(canonicalRoute) }));
@@ -19,6 +26,7 @@ export default function PanelLayout(props: PanelLayoutProps) {
19
26
  return (
20
27
  <LocaleProvider locale={getLocale(props.c)}>
21
28
  <div class="cloud-panel-page" data-cloud-panel-page="" data-panel-provider={app?.id}>
29
+ <script id="cloud-panel-context" type="application/json" innerHTML={JSON.stringify(context).replace(/</g, "\\u003c")} />
22
30
  <PanelRuntime provider={app?.id} scope={app?.panel ? new URL(app.panel.href, props.c.req.raw.url).pathname : undefined} />
23
31
  {props.children}
24
32
  </div>
@@ -1,5 +1,6 @@
1
1
  import { installAppWorkspaceController, installInputModeController, keyboardPlatform } from "@k2b/ui";
2
2
  import { onCleanup, onMount } from "solid-js";
3
+ import { installPanelContextReceiver } from "../browser/panel-context-receiver";
3
4
  import { syncTimezoneCookie } from "./timezone-cookie";
4
5
 
5
6
  export default function PanelRuntime(props: { provider?: string; scope?: string }) {
@@ -12,6 +13,7 @@ export default function PanelRuntime(props: { provider?: string; scope?: string
12
13
  const deferred = new Set<ReturnType<typeof setTimeout>>();
13
14
  const embedded = window.parent !== window;
14
15
  let disposeTheme = () => {};
16
+ let disposeContext = () => {};
15
17
  let resumeTheme = () => {};
16
18
  // A page kept in the back/forward cache keeps following the host; only a removed iframe lets go.
17
19
  const pageHide = (event: PageTransitionEvent) => {
@@ -38,6 +40,9 @@ export default function PanelRuntime(props: { provider?: string; scope?: string
38
40
  if (event.key.toLowerCase() === "j" && event.metaKey === apple && event.ctrlKey === !apple) {
39
41
  event.preventDefault();
40
42
  if (!event.repeat) close();
43
+ } else if (event.key.toLowerCase() === "k" && event.metaKey === apple && event.ctrlKey === !apple) {
44
+ event.preventDefault();
45
+ if (!event.repeat) window.parent.postMessage({ type: "cloud.panel.search", v: 1 }, location.origin);
41
46
  } else if (event.key === "Escape" && !event.metaKey && !event.ctrlKey) {
42
47
  // An open modal dialog takes Escape through its cancel event, without preventing this keydown.
43
48
  if (document.querySelector("dialog[open], [aria-modal='true']")) return;
@@ -49,6 +54,7 @@ export default function PanelRuntime(props: { provider?: string; scope?: string
49
54
  }
50
55
  };
51
56
  if (embedded) {
57
+ disposeContext = installPanelContextReceiver();
52
58
  try {
53
59
  const parent = window.parent;
54
60
  const parentRoot = parent.document.documentElement;
@@ -73,6 +79,7 @@ export default function PanelRuntime(props: { provider?: string; scope?: string
73
79
  window.addEventListener("click", click);
74
80
  }
75
81
  onCleanup(() => {
82
+ disposeContext();
76
83
  disposeInput();
77
84
  disposeWorkspace();
78
85
  disposeTheme();
@@ -1,5 +1,6 @@
1
- import { Button, IconButton, type KeyboardPlatform, Placeholder, ResizeSeparator, useLocale } from "@k2b/ui";
1
+ import { Button, IconButton, type KeyboardPlatform, NoticeCard, Placeholder, ResizeSeparator, useLocale } from "@k2b/ui";
2
2
  import { PANEL_MAX_WIDTH_REM, PANEL_MIN_WIDTH_REM, type PanelCookieState } from "../_internal/panel-cookie";
3
+ import { withPanelContext } from "../contracts/panel-context-plain";
3
4
  import { platformMessages } from "./platform-messages";
4
5
  import type { ShellData } from "./shell-data";
5
6
 
@@ -9,6 +10,10 @@ export default function PanelSlot(props: { panel: NonNullable<ShellData["panel"]
9
10
  const t = platformMessages.resolve([locale()]).t;
10
11
  const provider = props.panel.providers.find((provider) => provider.id === props.panel.provider) ?? props.panel.providers[0];
11
12
  if (!provider) return null;
13
+ const answer = props.panel.consent?.provider === provider.id ? props.panel.consent.allowed : null;
14
+ const ask = props.state.open && provider.consent && answer === null;
15
+ const allowed = !provider.consent || answer === true;
16
+ const hasConsent = props.panel.providers.some((provider) => provider.consent);
12
17
  const tabs = props.panel.providers.length > 1;
13
18
  return (
14
19
  <aside
@@ -18,6 +23,10 @@ export default function PanelSlot(props: { panel: NonNullable<ShellData["panel"]
18
23
  aria-label={t.panel}
19
24
  data-state={props.state.open ? "loading" : "closed"}
20
25
  data-provider={provider.id}
26
+ data-app-name={provider.appName}
27
+ data-provider-consent={String(provider.consent)}
28
+ data-context-allowed={String(allowed)}
29
+ data-consent={ask ? "ask" : undefined}
21
30
  >
22
31
  <ResizeSeparator
23
32
  class="cloud-panel__resize"
@@ -48,7 +57,7 @@ export default function PanelSlot(props: { panel: NonNullable<ShellData["panel"]
48
57
  tabindex={item.id === provider.id ? 0 : -1}
49
58
  >
50
59
  <i class={item.icon} aria-hidden="true" />
51
- <span class="cloud-panel__tab-label">{item.label}</span>
60
+ <span class="cloud-panel__tab-label k2b-truncate-fade">{item.label}</span>
52
61
  </button>
53
62
  ))}
54
63
  </div>
@@ -58,6 +67,19 @@ export default function PanelSlot(props: { panel: NonNullable<ShellData["panel"]
58
67
  <span>{provider.label}</span>
59
68
  </h2>
60
69
  )}
70
+ {/* The controller mounts the ⋯ menu over this button; the place stays reserved for Core providers. */}
71
+ {hasConsent && (
72
+ <span
73
+ class="cloud-panel__menu"
74
+ data-cloud-panel-menu=""
75
+ style={{ visibility: provider.consent ? "visible" : "hidden" }}
76
+ inert={!provider.consent}
77
+ >
78
+ <IconButton label={t.panelMenu} title={t.panelMenu} aria-haspopup="menu" aria-expanded="false">
79
+ <i class="ti ti-dots" aria-hidden="true" />
80
+ </IconButton>
81
+ </span>
82
+ )}
61
83
  <IconButton
62
84
  label={t.closePanel}
63
85
  title={`${t.closePanel} (${props.platform === "apple" ? "⌘J" : "Ctrl+J"})`}
@@ -67,6 +89,34 @@ export default function PanelSlot(props: { panel: NonNullable<ShellData["panel"]
67
89
  </IconButton>
68
90
  </div>
69
91
  <div class="cloud-panel__body" id="cloud-panel-body" role={tabs ? "tabpanel" : undefined}>
92
+ {hasConsent && (
93
+ // The shell asks, not the provider: the question sits above the provider page, which loads without context.
94
+ <NoticeCard
95
+ class="cloud-panel__consent"
96
+ tone="neutral"
97
+ data-cloud-panel-consent=""
98
+ role="region"
99
+ tabindex="-1"
100
+ aria-labelledby="cloud-panel-consent-title"
101
+ title={
102
+ <span id="cloud-panel-consent-title" data-cloud-panel-consent-title="">
103
+ {t.panelConsentTitle({ app: provider.appName })}
104
+ </span>
105
+ }
106
+ detail={<span data-cloud-panel-consent-body="">{t.panelConsentBody({ app: provider.appName })}</span>}
107
+ >
108
+ <div class="cloud-panel__consent-actions">
109
+ <Button size="sm" variant="primary" data-cloud-panel-consent-answer="allow">
110
+ {t.panelConsentAllow}
111
+ </Button>
112
+ <Button size="sm" variant="secondary" data-cloud-panel-consent-answer="deny">
113
+ {t.panelConsentDeny}
114
+ </Button>
115
+ </div>
116
+ {/* A failed save reports in this reserved line, so the question never grows. */}
117
+ <p class="cloud-panel__consent-error" data-cloud-panel-consent-error="" role="status" />
118
+ </NoticeCard>
119
+ )}
70
120
  <div class="cloud-panel__skeleton" aria-hidden="true">
71
121
  <span />
72
122
  <span />
@@ -76,7 +126,7 @@ export default function PanelSlot(props: { panel: NonNullable<ShellData["panel"]
76
126
  {props.state.open && (
77
127
  <iframe
78
128
  class="cloud-panel__frame"
79
- src={provider.href}
129
+ src={withPanelContext(provider.href, allowed ? props.panel.context : null)}
80
130
  title={provider.label}
81
131
  loading="lazy"
82
132
  data-cloud-panel-frame={provider.id}
@@ -13,7 +13,7 @@ import { appWorkspaceCookieName, readAppWorkspaceLayoutCookie } from "../_intern
13
13
  import { registerContextAwareCommand } from "../browser/command-bridge";
14
14
  import { attachCommandShortcuts } from "../browser/command-shortcuts";
15
15
  import { navigationSearchItems } from "../browser/navigation-search";
16
- import type { PanelRequest } from "../browser/panel";
16
+ import { openPanel, type PanelRequest } from "../browser/panel";
17
17
  import { importOnDemand } from "../browser/reload";
18
18
  import { registerGlobalSearchHost } from "../browser/search-bridge";
19
19
  import { attachShellFocusControls, readShellFocus, SHELL_FOCUS_CHANGE_EVENT, toggleShellFocus } from "../browser/shell-focus";
@@ -111,12 +111,18 @@ export default function ShellRuntime(props: ShellRuntimeProps) {
111
111
  event.preventDefault();
112
112
  togglePanel();
113
113
  };
114
+ // The panel controller loads lazily; it reads this mark to recheck consent after a restore it did not see.
115
+ const restored = (event: PageTransitionEvent) => {
116
+ if (event.persisted) document.documentElement.setAttribute("data-cloud-page-restored", "");
117
+ };
118
+ window.addEventListener("pageshow", restored);
114
119
  window.addEventListener("cloud.panel.request", request);
115
120
  if (!props.commands) window.addEventListener("keydown", key);
116
121
  if (document.querySelector('.cloud-app-canvas[data-panel="open"]')) void load();
117
122
  onCleanup(() => {
118
123
  stopped = true;
119
124
  dispose?.();
125
+ window.removeEventListener("pageshow", restored);
120
126
  window.removeEventListener("cloud.panel.request", request);
121
127
  window.removeEventListener("keydown", key);
122
128
  });
@@ -209,7 +215,10 @@ export default function ShellRuntime(props: ShellRuntimeProps) {
209
215
  : command.id === "cloud.search"
210
216
  ? { search: {} }
211
217
  : // Returning the load keeps the Command busy until the dialog is open; a second press opens no second dialog.
212
- () => importOnDemand(() => import("./LayoutHelp")).then((help) => help?.openLayoutHelpDialog()),
218
+ () => {
219
+ if (openPanel("core")) return;
220
+ return importOnDemand(() => import("./LayoutHelp")).then((help) => help?.openLayoutHelpDialog());
221
+ },
213
222
  }),
214
223
  );
215
224
  }
@@ -8,8 +8,9 @@ const fillShares = {
8
8
 
9
9
  /**
10
10
  * Opaque theme backgrounds checked for readable app ink; a test pins them to
11
- * tokens.css and @k2b/ui. `sidebar` is the workspace sidebar
12
- * (`--k2b-surface-muted`) and `sidebarHover` its row hover over it; `subtle`,
11
+ * tokens.css and @k2b/ui. `sidebar` is the workspace sidebar's own background
12
+ * (`--k2b-surface` since the one-surface workspace); a test reads that
13
+ * declaration from @k2b/ui. `sidebarHover` is its row hover over it; `subtle`,
13
14
  * `muted`, `hover`, and `platformSelected` are the `--ui-surface-subtle`,
14
15
  * `--ui-surface-muted`, `--ui-hover`, and blue `--ui-selected` fills app ink
15
16
  * also sits on.
@@ -18,8 +19,8 @@ export const appColorSurfaces = {
18
19
  light: {
19
20
  canvas: "#fafafa",
20
21
  surface: "#ffffff",
21
- sidebar: "#f4f4f5",
22
- sidebarHover: "#e8e8e9",
22
+ sidebar: "#ffffff",
23
+ sidebarHover: "#f3f3f3",
23
24
  subtle: "#f8f8f8",
24
25
  muted: "#f3f4f3",
25
26
  raised: "#ffffff",
@@ -29,8 +30,8 @@ export const appColorSurfaces = {
29
30
  dark: {
30
31
  canvas: "#090d12",
31
32
  surface: "#11151b",
32
- sidebar: "#20242a",
33
- sidebarHover: "#2b2f34",
33
+ sidebar: "#11151b",
34
+ sidebarHover: "#1d2026",
34
35
  subtle: "#161a20",
35
36
  muted: "#20242a",
36
37
  raised: "#181d24",
@@ -72,13 +73,17 @@ export const mixHex = (a: HexColor, b: HexColor, share: number): HexColor => {
72
73
  .join("")}`;
73
74
  };
74
75
 
75
- /** Derive selection fills and the nearest AA-readable app ink in each theme. */
76
+ /** Derive selection fills, the shell's open-app fill, and the nearest AA-readable app ink in each theme. */
76
77
  export const appColors = (accent: HexColor) => {
77
78
  const forTheme = (theme: keyof typeof appColorSurfaces) => {
78
79
  const surfaces = appColorSurfaces[theme];
79
80
  const selected = mixHex(accent, surfaces.surface, fillShares[theme].selected);
80
81
  const selectedNav = mixHex(accent, surfaces.sidebar, fillShares[theme].selectedNav);
81
- const backgrounds = [...Object.values(surfaces), selected, selectedNav];
82
+ // Cloud's shell marks the open app on its app-colored bar (global.css):
83
+ // with the surface in light, and in dark with the navigation share over the
84
+ // muted surface, which stays apart from the dark bar.
85
+ const shellSelected = theme === "light" ? surfaces.surface : mixHex(accent, surfaces.muted, fillShares.dark.selectedNav);
86
+ const backgrounds = [...Object.values(surfaces), selected, selectedNav, shellSelected];
82
87
  const toward = theme === "light" ? "#000000" : "#ffffff";
83
88
  let ink: HexColor = toward;
84
89
  for (let i = 100; i >= 0; i--) {
@@ -88,7 +93,7 @@ export const appColors = (accent: HexColor) => {
88
93
  break;
89
94
  }
90
95
  }
91
- return { ink, selected, selectedNav };
96
+ return { ink, selected, selectedNav, shellSelected };
92
97
  };
93
98
  return { light: forTheme("light"), dark: forTheme("dark") };
94
99
  };
@@ -1,6 +1,7 @@
1
1
  import { isOwnAppPath } from "../_internal/app-path";
2
2
  import { hasAnyAppRole } from "../_internal/app-roles";
3
3
  import type { RuntimeAppMeta } from "../contracts/app";
4
+ import { panelProviderNeedsConsent } from "../contracts/panel-context-plain";
4
5
  import { isPwaPartId } from "../contracts/pwa-paths";
5
6
  import type { User } from "../contracts/shared";
6
7
  import { resolveAppPresentation } from "../shared/app-presentation";
@@ -54,7 +55,7 @@ export const visiblePwaParts = (apps: readonly RuntimeAppMeta[], user: User | un
54
55
  .sort((a, b) => a.name.localeCompare(b.name, locale) || a.id.localeCompare(b.id));
55
56
  };
56
57
 
57
- export type PanelProvider = { id: string; label: string; icon: string; href: string };
58
+ export type PanelProvider = { id: string; label: string; icon: string; href: string; appName: string; consent: boolean };
58
59
 
59
60
  /** Authorized presentation only: the panel route still authorizes every request. */
60
61
  export const visiblePanelProviders = (apps: readonly RuntimeAppMeta[], user: User | undefined, locale: string): PanelProvider[] => {
@@ -62,8 +63,20 @@ export const visiblePanelProviders = (apps: readonly RuntimeAppMeta[], user: Use
62
63
  return apps
63
64
  .flatMap((app) => {
64
65
  if (!app.panel || !isOwnAppPath(app.panel.href, app.routes) || !hasAnyAppRole(user, app.panel.requiresRoles)) return [];
65
- const panel = resolveAppPresentation(app, locale).panel;
66
- return panel ? [{ id: app.id, label: panel.label, icon: panel.icon, href: panel.href }] : [];
66
+ const presentation = resolveAppPresentation(app, locale);
67
+ const panel = presentation.panel;
68
+ return panel
69
+ ? [
70
+ {
71
+ id: app.id,
72
+ label: panel.label,
73
+ icon: panel.icon,
74
+ href: panel.href,
75
+ appName: presentation.name,
76
+ consent: panelProviderNeedsConsent(app.id),
77
+ },
78
+ ]
79
+ : [];
67
80
  })
68
81
  .sort((a, b) => a.label.localeCompare(b.label, locale) || a.id.localeCompare(b.id));
69
82
  };
@@ -116,30 +116,38 @@ const workspace = (selected: string) =>
116
116
  * A task-list page: a long list in Main and an open detail that the server renders with its own close
117
117
  * control, as Contacts and Capabilities do. Invented content.
118
118
  */
119
- const listWorkspace = () =>
119
+ const listWorkspace = (head = false) =>
120
120
  createComponent(AppWorkspace, {
121
121
  class: "min-h-0 flex-1",
122
122
  get children() {
123
- return createComponent(AppWorkspace.Content, {
124
- get children() {
125
- return [
126
- createComponent(AppWorkspace.Main, {
127
- get children() {
128
- return Array.from({ length: 80 }, (_, index) =>
129
- createComponent(Button, { class: "list-row", align: "start", children: `Task ${index + 1}` }),
130
- );
131
- },
132
- }),
133
- createComponent(AppWorkspace.Detail, {
134
- id: "task-detail",
135
- open: true,
136
- get children() {
137
- return createComponent(Button, { "data-workspace-detail-close": "", children: "Close task" });
138
- },
139
- }),
140
- ];
141
- },
142
- });
123
+ return [
124
+ head ? createComponent(AppWorkspace.Head, { primary: { label: "New task", icon: "ti ti-plus", href: "?new=task" } }) : null,
125
+ createComponent(AppWorkspace.Content, {
126
+ get children() {
127
+ return [
128
+ createComponent(AppWorkspace.Main, {
129
+ get children() {
130
+ return Array.from({ length: 80 }, (_, index) =>
131
+ createComponent(Button, {
132
+ variant: head ? "ghost" : undefined,
133
+ class: "list-row",
134
+ align: "start",
135
+ children: `Task ${index + 1}`,
136
+ }),
137
+ );
138
+ },
139
+ }),
140
+ createComponent(AppWorkspace.Detail, {
141
+ id: "task-detail",
142
+ open: true,
143
+ get children() {
144
+ return createComponent(Button, { "data-workspace-detail-close": "", children: "Close task" });
145
+ },
146
+ }),
147
+ ];
148
+ },
149
+ }),
150
+ ];
143
151
  },
144
152
  });
145
153
 
@@ -270,7 +278,7 @@ const server = new Hono()
270
278
  fullPage: list === "page",
271
279
  title: [{ title: "Home", href: "/" }, { title: "Tasks" }],
272
280
  get children() {
273
- return listWorkspace();
281
+ return listWorkspace(c.req.query("head") === "1");
274
282
  },
275
283
  });
276
284
  return () =>
@@ -0,0 +1,43 @@
1
+ import { DataTable, prompts, SettingsModal } from "@k2b/ui";
2
+ import { render } from "solid-js/web";
3
+
4
+ const host = document.createElement("div");
5
+ document.querySelector(".cloud-app-canvas")!.append(host);
6
+
7
+ const SelectedTable = () => (
8
+ <DataTable
9
+ rows={[{ id: "selected", name: "Selected file" }]}
10
+ columns={[{ id: "name", header: "Name", value: "name" }]}
11
+ getRowId={(row) => row.id}
12
+ selectedRowId="selected"
13
+ surface="plain"
14
+ />
15
+ );
16
+
17
+ render(
18
+ () => (
19
+ <button
20
+ type="button"
21
+ onClick={() =>
22
+ void prompts.dialog<void>(
23
+ (close) => (
24
+ <div class="dialog-fixed-frame flex min-h-0 flex-col overflow-hidden">
25
+ <SettingsModal title="App settings" onClose={() => close()}>
26
+ <SettingsModal.Tab id="general" title="General">
27
+ <SelectedTable />
28
+ </SettingsModal.Tab>
29
+ <SettingsModal.Tab id="files" title="Files">
30
+ <SelectedTable />
31
+ </SettingsModal.Tab>
32
+ </SettingsModal>
33
+ </div>
34
+ ),
35
+ { surface: "bare", header: false, size: "large" },
36
+ )
37
+ }
38
+ >
39
+ Open app settings
40
+ </button>
41
+ ),
42
+ host,
43
+ );
@@ -20,6 +20,8 @@ export const helpMessages = i18n.define({
20
20
  shortcutsInApp: ({ app }: { app: string }) => `In ${app}`,
21
21
  shortcutsInThisApp: "In this app",
22
22
  shortcutsWithSelection: "With a selection",
23
+ helpNotAvailable: "This Help is not available",
24
+ helpNotAvailableDetail: "You may not have access to it, or it no longer exists.",
23
25
  noArticles: "No Help articles here yet",
24
26
  reload: "Reload",
25
27
  noArticlesDetail: "This app has not published Help yet.",
@@ -79,6 +81,8 @@ export const helpMessages = i18n.define({
79
81
  shortcutsInApp: ({ app }) => `In ${app}`,
80
82
  shortcutsInThisApp: "In dieser App",
81
83
  shortcutsWithSelection: "Mit Auswahl",
84
+ helpNotAvailable: "Diese Hilfe ist nicht verfügbar",
85
+ helpNotAvailableDetail: "Vielleicht hast du keinen Zugriff, oder es gibt sie nicht mehr.",
82
86
  noArticles: "Hier gibt es noch keine Hilfeartikel",
83
87
  reload: "Neu laden",
84
88
  noArticlesDetail: "Diese App hat noch keine Hilfe veröffentlicht.",