@k2b/cloud 0.36.0 → 0.38.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 (90) hide show
  1. package/package.json +5 -3
  2. package/scripts/README.md +1 -0
  3. package/scripts/cli-pack.ts +123 -0
  4. package/src/_internal/help-catalog.ts +123 -4
  5. package/src/_internal/help.ts +27 -6
  6. package/src/_internal/shell-focus.ts +11 -0
  7. package/src/access/PermissionEditor.browser-harness.tsx +46 -0
  8. package/src/access/PermissionEditor.tsx +38 -9
  9. package/src/access/ui.ts +1 -0
  10. package/src/account/Avatar.tsx +20 -2
  11. package/src/account/ui.ts +1 -1
  12. package/src/ai/browser.ts +1 -0
  13. package/src/ai/chart-block.ts +44 -13
  14. package/src/ai/chat/answer-links.ts +8 -0
  15. package/src/ai/chat/chart-block.tsx +2 -11
  16. package/src/ai/chat/messages.ts +12 -0
  17. package/src/ai/chat/primitives.tsx +2 -1
  18. package/src/ai/chat/web-tools.tsx +23 -6
  19. package/src/ai/chat-tasks.ts +65 -1
  20. package/src/ai/default-tool-contracts.ts +30 -13
  21. package/src/ai/executor.ts +46 -3
  22. package/src/ai/fetch-file-tool.ts +37 -3
  23. package/src/ai/migrate.ts +19 -2
  24. package/src/ai/readable-html.ts +1057 -0
  25. package/src/ai/types.ts +1 -0
  26. package/src/api/help.ts +23 -4
  27. package/src/api/mcp.ts +8 -2
  28. package/src/browser/FileChooser.tsx +11 -3
  29. package/src/browser/choose-files.browser-harness.tsx +4 -1
  30. package/src/browser/file-chooser-messages.ts +4 -0
  31. package/src/browser/file-providers.ts +14 -1
  32. package/src/browser/provider-folder.tsx +58 -5
  33. package/src/browser/resource-picker.tsx +5 -1
  34. package/src/browser/shell-focus.ts +67 -0
  35. package/src/cli/admin/workflows.ts +1 -0
  36. package/src/cli/commands.ts +2 -0
  37. package/src/cli/plugin.ts +5 -5
  38. package/src/config/env.ts +14 -0
  39. package/src/contracts/file-provider.ts +48 -1
  40. package/src/contracts/outgoing-mail.ts +8 -1
  41. package/src/server/help.ts +136 -10
  42. package/src/server/services/geo.ts +21 -4
  43. package/src/services/allowed-network-fetch.ts +189 -0
  44. package/src/services/help/index.ts +46 -5
  45. package/src/services/help/store.ts +26 -5
  46. package/src/services/help/types.ts +10 -2
  47. package/src/services/index.ts +2 -0
  48. package/src/services/network-security.ts +28 -0
  49. package/src/services/outgoing-mail/dispatcher.ts +7 -2
  50. package/src/services/outgoing-mail/resolve-host.ts +39 -0
  51. package/src/services/outgoing-mail/test-send.ts +18 -3
  52. package/src/services/outgoing-mail/transport.ts +24 -13
  53. package/src/services/settings/index.ts +48 -26
  54. package/src/shared/help.ts +41 -0
  55. package/src/shared/index.ts +9 -1
  56. package/src/shared/markdown/extensions/guided-help.ts +208 -64
  57. package/src/shared/markdown/formula.ts +8 -8
  58. package/src/shared/markdown/index.ts +72 -11
  59. package/src/shared/network-address.ts +9 -0
  60. package/src/ssr/CloudShell.island.tsx +2 -0
  61. package/src/ssr/GlobalSearchTrigger.tsx +1 -1
  62. package/src/ssr/Layout.tsx +28 -2
  63. package/src/ssr/LayoutHelp.browser-harness.tsx +130 -27
  64. package/src/ssr/LayoutHelp.tsx +569 -410
  65. package/src/ssr/LayoutHelpTrigger.tsx +1 -1
  66. package/src/ssr/LayoutRail.tsx +1 -1
  67. package/src/ssr/ProfilePreferences.tsx +1 -1
  68. package/src/ssr/RailApps.tsx +1 -1
  69. package/src/ssr/ShellFocusToggle.tsx +43 -0
  70. package/src/ssr/ShellRuntime.island.tsx +53 -26
  71. package/src/ssr/app-launchpad.tsx +1 -1
  72. package/src/ssr/help-messages.ts +60 -38
  73. package/src/ssr/help.ts +3 -0
  74. package/src/ssr/layout-help-article.ts +41 -0
  75. package/src/ssr/layout-help-navigation.ts +0 -12
  76. package/src/ssr/layout-help-registry.tsx +4 -11
  77. package/src/ssr/layout-help-search.ts +9 -12
  78. package/src/ssr/layout-help-start.ts +68 -0
  79. package/src/ssr/platform-messages.ts +14 -0
  80. package/src/ssr/shell-data.tsx +7 -0
  81. package/src/ssr/shell-focus.browser-harness.tsx +83 -0
  82. package/src/ssr/shell-shortcuts.ts +8 -0
  83. package/src/styles/effects.css +688 -185
  84. package/src/styles/global.css +155 -26
  85. package/src/styles/resource-search.css +25 -0
  86. package/src/styles/utilities-navigation.css +11 -3
  87. package/src/workflows/store/events.ts +24 -0
  88. package/src/workflows/store/index.ts +8 -0
  89. package/src/workflows/store/observability.ts +6 -1
  90. package/src/workflows/store/retention.ts +157 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@k2b/cloud",
3
- "version": "0.36.0",
3
+ "version": "0.38.0",
4
4
  "description": "Application platform library for independently deployed Hono and SolidJS services behind a dynamic gateway.",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "repository": {
@@ -19,6 +19,7 @@
19
19
  "!src/**/*.test.tsx",
20
20
  "!src/**/*.integration.test.ts",
21
21
  "!src/**/*.test-fixture.ts",
22
+ "!src/**/*.test-fixture.tsx",
22
23
  "!scripts/**/*.test.ts",
23
24
  "!scripts/runtime-recovery-*.ts"
24
25
  ],
@@ -106,8 +107,8 @@
106
107
  "@tabler/icons": "3.48.0",
107
108
  "@tailwindcss/typography": "0.5.20",
108
109
  "@k2b/nessi": "0.18.0",
109
- "@k2b/ssr": "0.16.0",
110
- "@k2b/ui": "0.26.0",
110
+ "@k2b/ssr": "0.17.0",
111
+ "@k2b/ui": "0.28.0",
111
112
  "@k2b/stdlib": "0.29.0",
112
113
  "@k2b/sync": "7.0.0",
113
114
  "@nats-io/jetstream": "3.4.0",
@@ -115,6 +116,7 @@
115
116
  "@modelcontextprotocol/sdk": "1.32.1",
116
117
  "bun-plugin-tailwind": "0.1.2",
117
118
  "hono-openapi": "1.3.3",
119
+ "htmlparser2": "12.0.0",
118
120
  "jose": "6.2.12",
119
121
  "katex": "0.18.9",
120
122
  "liquidjs": "10.29.0",
package/scripts/README.md CHANGED
@@ -3,6 +3,7 @@
3
3
  - `build.ts` — production build for one Cloud app (`APP_ID`); used by the Dockerfiles and `bun run build` targets.
4
4
  - `preload.ts` — dev-mode preload that registers the SSR plugin and builds CSS before app code loads.
5
5
  - `build-canvas-workers.ts` — packages the isolated canvas workers (PDF pages, mobile app icons) and the native canvas for bundles that start them.
6
+ - `cli-pack.ts` — packs a standalone `cld` module with its verified manifest and skill references: `bun run packages/cloud/scripts/cli-pack.ts <module.ts> --references <dir> --out <dir>`. Reads the package name and version from the current directory; publish the output with `npm publish <dir>` or archive it with `npm pack <dir>`.
6
7
  - `app-favicon.ts` — generates app favicons from Tabler icons; imported by `build.ts` and `preload.ts`.
7
8
  - `browser-performance.ts` — bundles the web-vitals browser asset for core; imported by `build.ts` and `preload.ts`.
8
9
  - `runtime-recovery-acceptance.ts` — shared-runtime recovery acceptance on disposable Docker containers: `bun packages/cloud/scripts/runtime-recovery-acceptance.ts` (also `bun run test:runtime-recovery`).
@@ -0,0 +1,123 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Pack a `cld` module as a package plugin, for authors without a Cloud
4
+ * application:
5
+ *
6
+ * bun run node_modules/@k2b/cloud/scripts/cli-pack.ts src/cli.ts --references src/cli-references --out dist/cld-plugin
7
+ *
8
+ * Writes the same verified bundle the application build writes to
9
+ * `dist/cli/<name>/` (`cli.js`, `references/`, `manifest.json`), plus a
10
+ * `package.json` whose `cld.manifest` points at the manifest. Name and version
11
+ * come from `package.json` in the current directory.
12
+ */
13
+ import { lstat, readdir, readFile, realpath, rm, writeFile } from "node:fs/promises";
14
+ import { isAbsolute, join, relative, resolve, sep } from "node:path";
15
+ import { pathToFileURL } from "node:url";
16
+ import { buildCliPlugin, writeCliPlugin } from "../src/_internal/cli-plugins";
17
+ import { validateCloudCliModule } from "../src/cli/plugin";
18
+
19
+ const USAGE = "Usage: bun run node_modules/@k2b/cloud/scripts/cli-pack.ts <module.ts> --references <dir> --out <dir>";
20
+
21
+ const HELP = `${USAGE}
22
+
23
+ Packs a cld module as a package plugin: the bundled module cli.js, the skill
24
+ references (index.md plus further Markdown files), manifest.json with the
25
+ SHA-512 of every file, and a package.json with "cld": { "manifest": "manifest.json" }.
26
+ Name and version come from package.json in the current directory.
27
+
28
+ --out must be missing, empty, or an earlier pack output, which is replaced.
29
+ Ship the result with \`npm publish <out>\` or \`npm pack <out>\`.`;
30
+
31
+ const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
32
+
33
+ const readJson = (path: string): Promise<unknown> =>
34
+ readFile(path, "utf8")
35
+ .then(JSON.parse)
36
+ .catch(() => null);
37
+
38
+ const parseArgs = (args: string[]) => {
39
+ const [module, ...rest] = args;
40
+ const flags = new Map<string, string>();
41
+ for (let index = 0; index < rest.length; index += 2) {
42
+ const [flag, value] = [rest[index]!, rest[index + 1]];
43
+ if (!["--references", "--out"].includes(flag) || !value || value.startsWith("-") || flags.has(flag)) throw new Error(USAGE);
44
+ flags.set(flag, value);
45
+ }
46
+ const references = flags.get("--references");
47
+ const out = flags.get("--out");
48
+ if (!module || module.startsWith("-") || !references || !out) throw new Error(USAGE);
49
+ return { module: resolve(module), references: resolve(references), out: resolve(out) };
50
+ };
51
+
52
+ const containsPath = (directory: string, path: string): boolean => {
53
+ const inside = relative(directory, path);
54
+ return inside !== ".." && !inside.startsWith(`..${sep}`) && !isAbsolute(inside);
55
+ };
56
+
57
+ /** Empty `out` for a fresh pack; anything but an earlier pack output stays untouched. */
58
+ const prepareOut = async (out: string, inputs: string[]): Promise<void> => {
59
+ const refusal = `Refusing --out ${out}: it must be missing, empty, or an earlier pack output.`;
60
+ const output = await realpath(out).catch(() => out);
61
+ for (const input of inputs) {
62
+ const path = await realpath(input).catch(() => input);
63
+ if (containsPath(out, input) || containsPath(output, path)) throw new Error(refusal);
64
+ }
65
+ const info = await lstat(out).catch(() => null);
66
+ if (!info) return;
67
+ if (!info.isDirectory()) throw new Error(refusal);
68
+ const entries = await readdir(out);
69
+ if (entries.length === 0) return;
70
+ const previous = await readJson(join(out, "package.json"));
71
+ if (
72
+ entries.some((entry) => !["package.json", "manifest.json", "cli.js", "references"].includes(entry)) ||
73
+ !isRecord(previous) ||
74
+ !isRecord(previous.cld) ||
75
+ previous.cld.manifest !== "manifest.json"
76
+ )
77
+ throw new Error(refusal);
78
+ await rm(out, { recursive: true });
79
+ };
80
+
81
+ const pack = async (args: string[]): Promise<void> => {
82
+ if (args.includes("--help") || args.includes("-h")) {
83
+ console.log(HELP);
84
+ return;
85
+ }
86
+ const options = parseArgs(args);
87
+ const cwd = process.cwd();
88
+ const manifest = await readJson(join(cwd, "package.json"));
89
+ if (
90
+ !isRecord(manifest) ||
91
+ typeof manifest.name !== "string" ||
92
+ !manifest.name ||
93
+ typeof manifest.version !== "string" ||
94
+ !manifest.version
95
+ ) {
96
+ throw new Error(`${join(cwd, "package.json")} needs a string name and version.`);
97
+ }
98
+ const { name: packageName, version } = manifest;
99
+ await prepareOut(options.out, [cwd, options.module, options.references]);
100
+ const loaded: unknown = await import(pathToFileURL(options.module).href);
101
+ const module = validateCloudCliModule(isRecord(loaded) ? loaded.default : undefined);
102
+ const plugin = await buildCliPlugin({
103
+ appDir: cwd,
104
+ appId: packageName,
105
+ name: module.name,
106
+ declaration: { module: options.module, references: options.references },
107
+ version,
108
+ });
109
+ await writeCliPlugin(options.out, plugin);
110
+ await writeFile(
111
+ join(options.out, "package.json"),
112
+ `${JSON.stringify({ name: packageName, version, type: "module", cld: { manifest: "manifest.json" } }, null, 2)}\n`,
113
+ );
114
+ const references = plugin.manifest.files.filter((file) => file.path.startsWith("references/")).length;
115
+ console.log(
116
+ `Packed ${packageName}@${version} (module "${module.name}", ${references} reference files) to ${options.out}. Ship it with \`npm publish ${options.out}\` or \`npm pack ${options.out}\`.`,
117
+ );
118
+ };
119
+
120
+ pack(Bun.argv.slice(2)).catch((error: unknown) => {
121
+ console.error(error instanceof Error ? error.message.replace(/\s*\n\s*/g, " ") : String(error));
122
+ process.exitCode = 1;
123
+ });
@@ -1,5 +1,124 @@
1
+ import { i18n } from "@k2b/stdlib";
2
+ import { formatShortcut } from "@k2b/ui";
3
+ import type { Token } from "marked";
1
4
  import type { HelpArticle } from "../services/help/types";
2
- import { markdownToPlainText } from "../shared/markdown";
5
+ import { parseHelpReference } from "../shared/help";
6
+ import { lexHelpMarkdown, markdownToPlainText } from "../shared/markdown";
7
+
8
+ const helpAgentMessages = i18n.define({
9
+ baseLocale: "en",
10
+ messages: {
11
+ en: { apple: ({ shortcut }: { shortcut: string }) => `${shortcut} on Mac` },
12
+ de: { apple: ({ shortcut }) => `${shortcut} auf dem Mac` },
13
+ },
14
+ });
15
+
16
+ const helpLinkLabel = (source: string, fallback: string): string => {
17
+ // Index backtick runs once so unmatched runs cannot repeatedly scan the remaining source.
18
+ const backticks = new Map<number, { positions: number[]; next: number }>();
19
+ for (const run of source.matchAll(/`+/g)) {
20
+ const length = run[0].length;
21
+ if (!backticks.has(length)) backticks.set(length, { positions: [], next: 0 });
22
+ backticks.get(length)!.positions.push(run.index);
23
+ }
24
+ let depth = 0;
25
+ for (let index = 1; index < source.length; index++) {
26
+ const char = source[index];
27
+ if (char === "\\") index++;
28
+ else if (char === "`") {
29
+ let end = index + 1;
30
+ while (source[end] === "`") end++;
31
+ const length = end - index;
32
+ const runs = backticks.get(length);
33
+ if (runs) {
34
+ while (runs.next < runs.positions.length && runs.positions[runs.next]! <= index) runs.next++;
35
+ }
36
+ const closing = runs?.positions[runs.next];
37
+ index = closing === undefined ? end - 1 : closing + length - 1;
38
+ } else if (char === "[") depth++;
39
+ else if (char === "]") {
40
+ if (depth === 0) return source.slice(1, index);
41
+ depth--;
42
+ }
43
+ }
44
+ return fallback;
45
+ };
46
+
47
+ /** Portable source for agents and Copy as Markdown; code is kept verbatim. */
48
+ export const helpAgentMarkdown = (
49
+ article: Pick<HelpArticle, "markdown" | "locale" | "appId" | "links">,
50
+ tokens: readonly Token[] = lexHelpMarkdown(article.markdown, article.locale),
51
+ ): string => {
52
+ const { t } = helpAgentMessages.resolve([article.locale]);
53
+ const rewriteChildren = (raw: string, children: readonly Token[]): string => {
54
+ let offset = 0;
55
+ let result = "";
56
+ for (const child of children) {
57
+ let start = raw.indexOf(child.raw, offset);
58
+ let source = child.raw;
59
+ if (start < 0 && child.raw.includes("|")) {
60
+ source = child.raw.replaceAll("|", "\\|");
61
+ start = raw.indexOf(source, offset);
62
+ }
63
+ // Marked removes quote markers and list indentation from child block sources.
64
+ if (start < 0 && child.raw.includes("\n")) {
65
+ const pattern = child.raw
66
+ .split("\n")
67
+ .map((line, index) => (index > 0 ? line.replace(/^[ \t]+/, "") : line).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
68
+ .join("\\r?\\n[ \\t>]*");
69
+ const match = new RegExp(pattern).exec(raw.slice(offset));
70
+ if (match) {
71
+ start = offset + match.index;
72
+ source = match[0];
73
+ }
74
+ }
75
+ if (start < 0) break;
76
+ result += raw.slice(offset, start) + rewrite(child, source);
77
+ offset = start + source.length;
78
+ }
79
+ return result + raw.slice(offset);
80
+ };
81
+ const rewrite = (token: Token, source: string): string => {
82
+ if (token.type === "code" || token.type === "codespan") return source;
83
+ if (token.type === "helpKbd") {
84
+ const keys = String(token.keys);
85
+ const other = formatShortcut(keys, { platform: "other", locale: article.locale });
86
+ const apple = formatShortcut(keys, { platform: "apple", locale: article.locale }).label;
87
+ // Key labels such as | or ` stay text in a table cell or a sentence.
88
+ const escape = (text: string) => text.replace(/[\\`*_[\]|<>~]/g, "\\$&");
89
+ // A key without a platform modifier reads by its name: "Enter", not "↵".
90
+ return other.label === apple ? escape(other.name) : `${escape(other.label)} (${t.apple({ shortcut: escape(apple) })})`;
91
+ }
92
+ if (token.type === "helpDetails") {
93
+ const newline = source.includes("\r\n") ? "\r\n" : "\n";
94
+ const bodyStart = source.indexOf("\n") + 1;
95
+ const closing = [...source.matchAll(/^[ \t>]*:::[ \t]*(?:\r?\n|$)/gm)].at(-1)!;
96
+ const body = source.slice(bodyStart, closing.index);
97
+ const prefix = body.match(/^[ \t>]+/)?.[0] ?? "";
98
+ const trailing = source.slice(closing.index + closing[0].length);
99
+ return `**${rewriteChildren(String(token.title), token.titleTokens ?? [])}**${newline}${prefix}${newline}${rewriteChildren(body, token.tokens ?? [])}${trailing}`;
100
+ }
101
+ if (token.type === "helpLabel") return rewriteChildren(source.replace(/\{icon="[a-z0-9-]+"\}$/, ""), token.tokens ?? []);
102
+ if (token.type === "link" && token.href.startsWith("help:")) {
103
+ const ref = parseHelpReference(token.href.slice(5));
104
+ const target = ref && article.links[`${ref.appId ?? article.appId}/${ref.documentId}`];
105
+ const label = rewriteChildren(helpLinkLabel(source, token.text), token.tokens ?? []);
106
+ return target ? `[${label}](${target.url}${ref.fragment})` : label;
107
+ }
108
+ const children =
109
+ token.type === "list"
110
+ ? token.items
111
+ : token.type === "table"
112
+ ? [...token.header, ...token.rows.flat()].flatMap((cell) => cell.tokens)
113
+ : "tokens" in token
114
+ ? (token.tokens ?? [])
115
+ : [];
116
+ const raw = rewriteChildren(source, children);
117
+ return token.type === "heading" ? raw.replace(/[ \t]+\{icon="[a-z0-9-]+"\}(?=[ \t]*\r?\n?$)/i, "") : raw;
118
+ };
119
+ return rewriteChildren(article.markdown, tokens);
120
+ };
121
+
3
122
  export const HELP_SEARCH_MAX_LIMIT = 25;
4
123
  export const HELP_READ_MAX_CHARS = 7_000;
5
124
  const normalizeSearchText = (value: string): string =>
@@ -33,7 +152,7 @@ const boundedExcerpt = (markdown: string, terms: readonly string[]): string => {
33
152
  return `${start > 0 ? `${marker}\n\n` : ""}${body}${start + budget < markdown.length ? `\n\n${marker}` : ""}`;
34
153
  };
35
154
 
36
- export const selectHelpMarkdown = (markdown: string, query?: string): { markdown: string; truncated: boolean } => {
155
+ export const selectHelpMarkdown = (markdown: string, query?: string, locale?: string): { markdown: string; truncated: boolean } => {
37
156
  // An exact heading identifies one complete reference section, even in short articles.
38
157
  const headingQuery = query?.trim().toLocaleLowerCase();
39
158
  const exact =
@@ -53,7 +172,7 @@ export const selectHelpMarkdown = (markdown: string, query?: string): { markdown
53
172
  const phrase = normalizeSearchText(query ?? "");
54
173
  const ranked = splitSections(markdown)
55
174
  .map((section, index) => {
56
- const text = normalizeSearchText(markdownToPlainText(section));
175
+ const text = normalizeSearchText(markdownToPlainText(section, locale));
57
176
  const score = (phrase && text.includes(phrase) ? 10 : 0) + terms.filter((term) => text.includes(term)).length;
58
177
  return { section, index, score };
59
178
  })
@@ -79,7 +198,7 @@ export const readHelpArticle = (document: HelpArticle, query?: string) => ({
79
198
  documentId: document.documentId,
80
199
  title: document.title,
81
200
  description: document.description,
82
- ...selectHelpMarkdown(document.markdown, query),
201
+ ...selectHelpMarkdown(helpAgentMarkdown(document), query, document.locale),
83
202
  });
84
203
 
85
204
  export const helpResourceUri = (appId: string, documentId: string): string =>
@@ -2,28 +2,49 @@ import { createHash } from "node:crypto";
2
2
  import type { AppRegistryHelpSummary } from "../contracts/registry";
3
3
  import type { HelpDefinition } from "../server/help";
4
4
  import type { HelpCorpus, HelpDocument } from "../services/help/types";
5
+ import { parseHelpReference } from "../shared/help";
6
+ import { lexHelpMarkdown, walkHelpTokens } from "../shared/markdown";
5
7
 
6
8
  export const HELP_DOCUMENT_MAX_BYTES = 128 * 1024;
9
+ // defineHelp documents are frozen; retain only their validated targets, not the token trees.
10
+ export const helpLinkTargets = new WeakMap<object, readonly string[]>();
7
11
  export type CompiledHelp = { summary: AppRegistryHelpSummary; corpus: HelpCorpus };
8
12
 
9
13
  export const compileHelp = (input: { appId: string; basePath?: string; definition: HelpDefinition }): CompiledHelp => {
10
- const compileDocuments = (source: HelpDefinition["documents"]): HelpDocument[] =>
11
- source.map(({ id, title, icon, description, order, markdown, searchText }) => {
14
+ const baseIds = new Set(input.definition.documents.map((document) => document.id));
15
+ const compileDocuments = (source: HelpDefinition["documents"], locale: string): HelpDocument[] =>
16
+ source.map((document) => {
17
+ const { id, title, icon, description, order, kind, needs, next, related, markdown, searchText } = document;
12
18
  if (Buffer.byteLength(markdown) > HELP_DOCUMENT_MAX_BYTES) {
13
19
  throw new Error(`Help document "${id}" exceeds the ${HELP_DOCUMENT_MAX_BYTES}-byte limit`);
14
20
  }
15
- return { id, title, icon, description, order, markdown, searchText };
21
+ const validate = (reference: string) => {
22
+ const ref = parseHelpReference(reference);
23
+ if (ref?.appId === input.appId && !baseIds.has(ref.documentId))
24
+ throw new Error(`Help locale "${locale}" document "${id}" has unknown reference "${reference}"`);
25
+ };
26
+ for (const reference of related) validate(reference);
27
+ const targets = helpLinkTargets.get(document);
28
+ if (targets) {
29
+ for (const target of targets) validate(target);
30
+ } else {
31
+ walkHelpTokens(lexHelpMarkdown(markdown, locale), (token) => {
32
+ if (token.type === "link" && token.href.startsWith("help:")) validate(token.href.slice(5));
33
+ });
34
+ }
35
+ return { id, title, icon, description, order, kind, needs, next, related, markdown, searchText };
16
36
  });
17
37
  const baseLocale = input.definition.baseLocale ?? "en";
18
- const documents = compileDocuments(input.definition.documents);
38
+ const documents = compileDocuments(input.definition.documents, baseLocale);
19
39
  const documentsByLocale = Object.fromEntries(
20
40
  Object.entries(input.definition.documentsByLocale ?? {})
21
41
  .filter(([locale]) => locale !== baseLocale)
22
42
  .sort(([a], [b]) => a.localeCompare(b))
23
- .map(([locale, docs]) => [locale, compileDocuments(docs)]),
43
+ .map(([locale, docs]) => [locale, compileDocuments(docs, locale)]),
24
44
  );
45
+ // A revision bump republishes every corpus, here to fill the new metadata columns.
25
46
  const manifestHash = createHash("sha256")
26
- .update(JSON.stringify({ revision: 1, appId: input.appId, baseLocale, documents, documentsByLocale }))
47
+ .update(JSON.stringify({ revision: 2, appId: input.appId, baseLocale, documents, documentsByLocale }))
27
48
  .digest("hex");
28
49
  return {
29
50
  summary: { manifestHash, pageBase: `${input.basePath?.replace(/\/$/, "") ?? ""}/help`, baseLocale },
@@ -0,0 +1,11 @@
1
+ export const SHELL_FOCUS_COOKIE = "cloud_shell_focus";
2
+
3
+ export const readShellFocusCookie = (cookieHeader: string | null | undefined): boolean =>
4
+ cookieHeader
5
+ ?.split(";")
6
+ .map((part) => part.trim())
7
+ .find((part) => part.startsWith(`${SHELL_FOCUS_COOKIE}=`))
8
+ ?.slice(SHELL_FOCUS_COOKIE.length + 1) === "1";
9
+
10
+ export const shellFocusCookie = (hidden: boolean, secure: boolean): string =>
11
+ `${SHELL_FOCUS_COOKIE}=${hidden ? "1" : ""}; Path=/; Max-Age=${hidden ? 31536000 : 0}; SameSite=Lax${secure ? "; Secure" : ""}`;
@@ -0,0 +1,46 @@
1
+ import { LocaleProvider } from "@k2b/ui";
2
+ import { render } from "solid-js/web";
3
+ import PermissionEditor from "./PermissionEditor";
4
+
5
+ const host = document.getElementById("root");
6
+ if (!host) throw new Error("Missing harness root");
7
+ render(
8
+ () => (
9
+ <LocaleProvider locale="en">
10
+ <PermissionEditor
11
+ initialEntries={["Mara Lind", "Lea Sommer", "Jonas Weber"].map((displayName, index) => ({
12
+ id: `access-${index}`,
13
+ principal: { type: "user", userId: `user-${index}` },
14
+ displayName,
15
+ permission: "read",
16
+ createdAt: "2026-10-15T07:00:00.000Z",
17
+ }))}
18
+ // Equal-width labels isolate pending-state layout from a normal change in label width.
19
+ allowedLevels={[
20
+ { level: "read", label: "Level 1" },
21
+ { level: "write", label: "Level 2" },
22
+ ]}
23
+ allowAuthenticated={false}
24
+ grantAccess={async () => {
25
+ throw new Error("Not used");
26
+ }}
27
+ updateAccess={() =>
28
+ new Promise<void>((resolve) => {
29
+ // The test waits for this mark, so it does not depend on how the editor shows a pending change.
30
+ document.documentElement.dataset.saving = "true";
31
+ document.addEventListener(
32
+ "settle-access",
33
+ () => {
34
+ delete document.documentElement.dataset.saving;
35
+ resolve();
36
+ },
37
+ { once: true },
38
+ );
39
+ })
40
+ }
41
+ revokeAccess={async () => {}}
42
+ />
43
+ </LocaleProvider>
44
+ ),
45
+ host,
46
+ );
@@ -236,8 +236,16 @@ export default function PermissionEditor(props: PermissionEditorProps) {
236
236
  };
237
237
 
238
238
  type LevelChange = { accessId: string; permission: GrantableLevel; scope: string | undefined };
239
+ // The row whose change is saving: it shows the chosen level with a spinner, the other rows wait.
240
+ const [updating, setUpdating] = createSignal<LevelChange | null>(null);
241
+ const [revoking, setRevoking] = createSignal<string | null>(null);
242
+ const updatingRow = (accessId: string) => {
243
+ const change = updating();
244
+ return change?.accessId === accessId ? change : undefined;
245
+ };
239
246
  const updateMut = mutation.create<LevelChange, LevelChange>({
240
247
  mutation: async (data) => {
248
+ setUpdating(data);
241
249
  await props.updateAccess(data.accessId, data.permission, data.scope);
242
250
  return data;
243
251
  },
@@ -247,6 +255,9 @@ export default function PermissionEditor(props: PermissionEditorProps) {
247
255
  }
248
256
  },
249
257
  onError: (err) => prompts.error(err.message),
258
+ onFinally: () => {
259
+ if (!disposed) setUpdating(null);
260
+ },
250
261
  });
251
262
 
252
263
  const revokeMut = mutation.create<string | null, AccessEntry>({
@@ -257,6 +268,7 @@ export default function PermissionEditor(props: PermissionEditorProps) {
257
268
  variant: "danger",
258
269
  });
259
270
  if (!confirmed) return null;
271
+ setRevoking(entry.id);
260
272
  await props.revokeAccess(entry.id);
261
273
  return entry.id;
262
274
  },
@@ -264,6 +276,9 @@ export default function PermissionEditor(props: PermissionEditorProps) {
264
276
  if (accessId) setEntries((current) => current.filter((entry) => entry.id !== accessId));
265
277
  },
266
278
  onError: (err) => prompts.error(err.message),
279
+ onFinally: () => {
280
+ if (!disposed) setRevoking(null);
281
+ },
267
282
  });
268
283
  const busy = () => grantPending() || updateMut.loading() || revokeMut.loading();
269
284
  // The service refuses to remove the last manager; the row says so up front.
@@ -288,6 +303,8 @@ export default function PermissionEditor(props: PermissionEditorProps) {
288
303
  coverage={coverage}
289
304
  canEdit={canEdit()}
290
305
  pending={entry.id === pending()}
306
+ updating={updatingRow(entry.id)}
307
+ revoking={entry.id === revoking()}
291
308
  disabled={busy()}
292
309
  lastManager={entry.id === lastManagerId()}
293
310
  allowed={allowed(entry.principal)}
@@ -332,8 +349,14 @@ export default function PermissionEditor(props: PermissionEditorProps) {
332
349
  function AccessEntryRow(props: {
333
350
  entry: AccessEntry;
334
351
  canEdit: boolean;
352
+ /** Another change is saving: the row keeps its controls, disabled. */
335
353
  disabled: boolean;
354
+ /** This row's grant is saving. */
336
355
  pending?: boolean;
356
+ /** This row's level change is saving. */
357
+ updating?: { permission: GrantableLevel; scope: string | undefined };
358
+ /** This row's removal is saving. */
359
+ revoking?: boolean;
337
360
  /** The only entry that manages the resource: it keeps its level and cannot be removed. */
338
361
  lastManager: boolean;
339
362
  allowed: ResolvedLevel[];
@@ -352,8 +375,14 @@ function AccessEntryRow(props: {
352
375
  const display = () => resolveEntryDisplay(props.entry.permission, props.entry.scope, props.allowed, t());
353
376
  const manageLabel = () => resolveEntryDisplay("admin", undefined, props.allowed, t()).label;
354
377
  const removeLabel = () => (props.lastManager ? t().lastManager({ level: manageLabel() }) : t().remove({ name: displayName() }));
355
- const isInteractive = () =>
356
- props.canEdit && !props.disabled && !props.pending && !props.singlePicker && props.allowed.some((option) => option.key === entryKey());
378
+ // Whether the row offers a picker depends only on what the viewer may do, never on a change in flight, so a
379
+ // saving change never swaps the picker for the badge and the rows keep their size.
380
+ const hasPicker = () => props.canEdit && !props.singlePicker && props.allowed.some((option) => option.key === entryKey());
381
+ const saving = () => props.pending || props.updating !== undefined || props.revoking === true;
382
+ // While a level change saves, the picker already shows the chosen level.
383
+ const shownKey = () => (props.updating ? levelKey(props.updating.permission, props.updating.scope) : entryKey());
384
+ const shownDisplay = () =>
385
+ props.updating ? resolveEntryDisplay(props.updating.permission, props.updating.scope, props.allowed, t()) : display();
357
386
 
358
387
  const badgeClass =
359
388
  "flex min-h-7 items-center gap-1 rounded-full border border-transparent bg-[var(--ui-surface-muted)] px-2.5 py-1 text-xs text-secondary";
@@ -362,15 +391,12 @@ function AccessEntryRow(props: {
362
391
  <>
363
392
  <i class={props.pending ? "ti ti-loader-2 animate-spin" : `ti ${display().icon}`} />
364
393
  <span>{display().label}</span>
365
- <Show when={isInteractive()}>
366
- <i class="ti ti-chevron-down text-[10px]" />
367
- </Show>
368
394
  </>
369
395
  );
370
396
 
371
397
  return (
372
398
  <div>
373
- <div class="group/access-row flex items-center gap-2 py-1.5" aria-busy={props.pending ? "true" : undefined}>
399
+ <div class="group/access-row flex items-center gap-2 py-1.5" aria-busy={saving() ? "true" : undefined}>
374
400
  <Show
375
401
  when={props.entry.principal.type === "user"}
376
402
  fallback={
@@ -415,10 +441,12 @@ function AccessEntryRow(props: {
415
441
 
416
442
  {/* Permission badge — interactive single-value picker when editable,
417
443
  plain span otherwise. */}
418
- <Show when={isInteractive()} fallback={<span class={`${badgeClass} cursor-default`}>{badgeContent}</span>}>
444
+ <Show when={hasPicker()} fallback={<span class={`${badgeClass} cursor-default`}>{badgeContent}</span>}>
419
445
  <SelectChip
420
446
  aria-label={t().permissionFor({ name: displayName() })}
421
- value={entryKey}
447
+ value={shownKey}
448
+ disabled={props.disabled}
449
+ loading={props.pending || props.updating !== undefined}
422
450
  options={props.allowed.map((option) => {
423
451
  const locked = props.lastManager && (option.level !== "admin" || option.scope !== undefined);
424
452
  return {
@@ -429,7 +457,7 @@ function AccessEntryRow(props: {
429
457
  description: locked ? t().lastManagerOption({ level: manageLabel() }) : option.description,
430
458
  };
431
459
  })}
432
- icon={`ti ${display().icon}`}
460
+ icon={`ti ${shownDisplay().icon}`}
433
461
  position="bottom-left"
434
462
  onValueChange={(key) => {
435
463
  const option = props.allowed.find((candidate) => candidate.key === key);
@@ -452,6 +480,7 @@ function AccessEntryRow(props: {
452
480
  size="xs"
453
481
  onClick={props.onRevoke}
454
482
  disabled={props.disabled || props.pending || props.lastManager}
483
+ loading={props.revoking}
455
484
  aria-label={t().remove({ name: displayName() })}
456
485
  // A disabled button takes no focus and touch has no hover, so the reason also reaches assistive technology.
457
486
  aria-description={props.lastManager ? removeLabel() : undefined}
package/src/access/ui.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  * from depending on Cloud's broad legacy UI entrypoint.
7
7
  */
8
8
 
9
+ export { isManagerEntry, type ManagerCandidate } from "./managers";
9
10
  export type { AllowedLevel, GrantableLevel } from "./PermissionEditor";
10
11
  export { default as PermissionEditor } from "./PermissionEditor";
11
12
  export { default as PrincipalPicker, principalKey } from "./PrincipalPicker";
@@ -1,10 +1,14 @@
1
1
  import { Avatar, type AvatarProps } from "@k2b/ui";
2
2
  import type { JSX } from "solid-js";
3
3
 
4
- export type CloudAvatarProps = Omit<AvatarProps, "name" | "src"> & {
4
+ /** Who an avatar stands for; agents and service accounts are square, people round. */
5
+ export type CloudAvatarKind = "user" | "service_account" | "system";
6
+
7
+ export type CloudAvatarProps = Omit<AvatarProps, "name" | "src" | "shape" | "icon"> & {
5
8
  username: string;
6
9
  userId?: string | null;
7
10
  avatarHash?: string | null;
11
+ kind?: CloudAvatarKind;
8
12
  };
9
13
 
10
14
  const avatarSource = (userId?: string | null, avatarHash?: string | null): string | undefined => {
@@ -12,14 +16,28 @@ const avatarSource = (userId?: string | null, avatarHash?: string | null): strin
12
16
  return `/api/accounts/users/${encodeURIComponent(userId)}/avatar?rev=${encodeURIComponent(avatarHash)}`;
13
17
  };
14
18
 
19
+ /** The URL of a person's uploaded picture, or undefined when there is none. */
20
+ export const cloudAvatarSource = avatarSource;
21
+
22
+ const KIND_ICON: Record<CloudAvatarKind, string | undefined> = {
23
+ user: undefined,
24
+ service_account: "ti ti-robot",
25
+ system: "ti ti-settings-automation",
26
+ };
27
+
28
+ /** One avatar for every Cloud surface: the uploaded picture when there is one, otherwise initials. */
15
29
  export function CloudAvatar(props: CloudAvatarProps): JSX.Element {
30
+ const kind = () => props.kind ?? "user";
16
31
  return (
17
32
  <Avatar
18
33
  name={props.username}
19
- src={avatarSource(props.userId, props.avatarHash)}
34
+ src={kind() === "user" ? avatarSource(props.userId, props.avatarHash) : undefined}
35
+ icon={KIND_ICON[kind()]}
36
+ shape={kind() === "user" ? "circle" : "square"}
20
37
  alt={props.alt}
21
38
  fallback={props.fallback}
22
39
  size={props.size}
40
+ ring={props.ring}
23
41
  loading={props.loading}
24
42
  class={props.class}
25
43
  style={props.style}
package/src/account/ui.ts CHANGED
@@ -1,3 +1,3 @@
1
- export { CloudAvatar, type CloudAvatarProps } from "./Avatar";
1
+ export { CloudAvatar, type CloudAvatarKind, type CloudAvatarProps, cloudAvatarSource } from "./Avatar";
2
2
  export { type AvatarUploadDialogOptions, openAvatarUploadDialog } from "./avatar-upload-dialog";
3
3
  export { default as EntitySearch, type EntitySearchPrincipal } from "./EntitySearch";
package/src/ai/browser.ts CHANGED
@@ -25,6 +25,7 @@ export {
25
25
  cloudAiChartRenderOptions,
26
26
  cloudAiChartRows,
27
27
  cloudAiChartTable,
28
+ cloudAiChartTooltip,
28
29
  cloudAiChartXValueFormat,
29
30
  cloudAiNumberFormatters,
30
31
  } from "./chart-block";