@k2b/cloud 0.27.0 → 0.29.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 (155) hide show
  1. package/package.json +7 -5
  2. package/src/_internal/cli-plugins.ts +18 -0
  3. package/src/_internal/define-app.ts +21 -5
  4. package/src/_internal/registry-validation.ts +30 -1
  5. package/src/_internal/runtime-context.ts +1 -0
  6. package/src/access/PermissionEditor.tsx +11 -4
  7. package/src/ai/assistant-models.ts +25 -4
  8. package/src/ai/audio-tool.ts +3 -10
  9. package/src/ai/browser-code-contracts.ts +33 -63
  10. package/src/ai/browser.ts +1 -0
  11. package/src/ai/capabilities.ts +35 -9
  12. package/src/ai/chat/blocks.tsx +113 -24
  13. package/src/ai/chat/builtin-tools.tsx +3 -1
  14. package/src/ai/chat/composer-adapter.ts +8 -0
  15. package/src/ai/chat/live-turn.browser-harness.tsx +15 -3
  16. package/src/ai/chat/message-actions.tsx +124 -102
  17. package/src/ai/chat/message-utils.ts +57 -6
  18. package/src/ai/chat/messages.ts +214 -0
  19. package/src/ai/chat/presentation.tsx +67 -28
  20. package/src/ai/chat/tool-groups.ts +1 -1
  21. package/src/ai/chat/tool-names.ts +157 -0
  22. package/src/ai/chat/turn-error.ts +21 -0
  23. package/src/ai/chat/turn-layout.ts +12 -0
  24. package/src/ai/chat/turn-view.tsx +69 -10
  25. package/src/ai/chat/user-message.tsx +19 -14
  26. package/src/ai/chat/visual-tools.tsx +1 -1
  27. package/src/ai/chat-tasks.ts +16 -3
  28. package/src/ai/client/controller.ts +23 -6
  29. package/src/ai/code-mode-skill.ts +21 -25
  30. package/src/ai/code-runtime-tools.ts +5 -1
  31. package/src/ai/code-source-contracts.ts +2 -2
  32. package/src/ai/compaction.ts +22 -3
  33. package/src/ai/data-analysis-skill.ts +3 -3
  34. package/src/ai/default-tools.ts +23 -17
  35. package/src/ai/executor.ts +262 -99
  36. package/src/ai/file-media-type.ts +4 -0
  37. package/src/ai/files-store.ts +17 -10
  38. package/src/ai/http.ts +11 -10
  39. package/src/ai/index.ts +3 -0
  40. package/src/ai/memories.ts +2 -0
  41. package/src/ai/memory-learning.ts +59 -5
  42. package/src/ai/memory-workflow-evidence.ts +35 -10
  43. package/src/ai/open-tool-calls.ts +87 -0
  44. package/src/ai/projects-routes.ts +5 -1
  45. package/src/ai/projects.ts +5 -1
  46. package/src/ai/protocol.ts +34 -1
  47. package/src/ai/provider.ts +16 -1
  48. package/src/ai/quota-provider.ts +4 -1
  49. package/src/ai/routes.ts +26 -10
  50. package/src/ai/run-timeout.ts +4 -5
  51. package/src/ai/runtime.ts +8 -0
  52. package/src/ai/settings.ts +54 -17
  53. package/src/ai/skill-seeds.ts +14 -6
  54. package/src/ai/store.ts +102 -34
  55. package/src/ai/structured.ts +2 -1
  56. package/src/ai/system-prompt.ts +9 -22
  57. package/src/ai/transcription.ts +58 -13
  58. package/src/ai/turn-failure.ts +104 -0
  59. package/src/ai/turn-guidance.ts +505 -0
  60. package/src/ai/turn-policy.ts +2 -1
  61. package/src/ai/types.ts +29 -1
  62. package/src/ai/ui.tsx +1 -0
  63. package/src/api/admin-core-settings.ts +16 -1
  64. package/src/api/admin-outgoing-mail.ts +185 -5
  65. package/src/api/me.ts +42 -1
  66. package/src/browser/FileChooser.tsx +82 -295
  67. package/src/browser/FileSaver.tsx +407 -0
  68. package/src/browser/app-badges.ts +16 -0
  69. package/src/browser/choose-files.browser-harness.tsx +22 -6
  70. package/src/browser/choose-files.tsx +23 -36
  71. package/src/browser/file-chooser-messages.ts +66 -0
  72. package/src/browser/file-providers.ts +258 -8
  73. package/src/browser/files.tsx +26 -3
  74. package/src/browser/notifications.ts +37 -1
  75. package/src/browser/provider-folder.tsx +301 -0
  76. package/src/browser/provider-list.ts +39 -0
  77. package/src/browser/save-files.browser-harness.tsx +35 -0
  78. package/src/browser/save-files.tsx +107 -0
  79. package/src/cli/admin/index.ts +3 -1
  80. package/src/cli/admin/notifications.ts +6 -0
  81. package/src/cli/admin/outgoing-mail.ts +104 -7
  82. package/src/contracts/app.ts +10 -0
  83. package/src/contracts/commands.ts +3 -1
  84. package/src/contracts/notification-group.ts +6 -0
  85. package/src/contracts/notification-types.ts +6 -0
  86. package/src/contracts/outgoing-mail.ts +189 -1
  87. package/src/contracts/registry.ts +1 -0
  88. package/src/contracts/user-notifications.ts +74 -0
  89. package/src/server/middleware/request-logger.ts +11 -2
  90. package/src/services/accounts/avatar.ts +5 -2
  91. package/src/services/help/store.ts +2 -1
  92. package/src/services/image-metadata.ts +438 -0
  93. package/src/services/index.ts +12 -1
  94. package/src/services/logging/redaction.ts +50 -0
  95. package/src/services/mandates/index.ts +4 -2
  96. package/src/services/mandates/policy.ts +20 -0
  97. package/src/services/notifications/batches.ts +139 -79
  98. package/src/services/notifications/browser.ts +48 -15
  99. package/src/services/notifications/channels.ts +33 -13
  100. package/src/services/notifications/dispatcher.ts +88 -6
  101. package/src/services/notifications/email-frame.fixture.html +51 -0
  102. package/src/services/notifications/{email.ts → email-frame.ts} +12 -35
  103. package/src/services/notifications/email-mail.ts +101 -0
  104. package/src/services/notifications/index.ts +49 -36
  105. package/src/services/notifications/observability.ts +9 -1
  106. package/src/services/notifications/platform.ts +33 -5
  107. package/src/services/notifications/preview.ts +23 -0
  108. package/src/services/notifications/quiet.ts +153 -0
  109. package/src/services/notifications/runtime.ts +9 -3
  110. package/src/services/notifications/user.ts +6 -0
  111. package/src/services/outgoing-mail/admin.ts +84 -0
  112. package/src/services/outgoing-mail/attachments.ts +135 -0
  113. package/src/services/outgoing-mail/bulk.ts +11 -0
  114. package/src/services/outgoing-mail/dispatcher.ts +231 -0
  115. package/src/services/outgoing-mail/drain.ts +60 -0
  116. package/src/services/outgoing-mail/enqueue.ts +180 -0
  117. package/src/services/outgoing-mail/index.ts +103 -10
  118. package/src/services/outgoing-mail/message.ts +14 -0
  119. package/src/services/outgoing-mail/messages.ts +431 -0
  120. package/src/services/outgoing-mail/retention.ts +48 -0
  121. package/src/services/outgoing-mail/runtime.ts +72 -0
  122. package/src/services/outgoing-mail/send.ts +130 -0
  123. package/src/services/outgoing-mail/store.ts +115 -4
  124. package/src/services/outgoing-mail/sync.ts +39 -0
  125. package/src/services/outgoing-mail/transport.ts +5 -1
  126. package/src/services/pdf/markdown.ts +22 -4
  127. package/src/services/pdf/template-preview.ts +2 -1
  128. package/src/services/postgres.ts +15 -0
  129. package/src/services/settings/app.ts +3 -0
  130. package/src/services/settings/core-settings.ts +18 -0
  131. package/src/services/settings/store.ts +4 -1
  132. package/src/services/weather/geo.ts +6 -2
  133. package/src/shared/ai-platform-prompt.ts +48 -4
  134. package/src/shared/markdown/extensions/links.ts +28 -20
  135. package/src/shared/markdown/index.ts +14 -5
  136. package/src/shared/markdown/shared.ts +0 -7
  137. package/src/shared/redirect.ts +2 -1
  138. package/src/shared/template-rendering.ts +120 -7
  139. package/src/ssr/AppBadge.tsx +30 -0
  140. package/src/ssr/AppLaunchpadPanel.tsx +47 -26
  141. package/src/ssr/GlobalAnnouncements.island.tsx +1 -1
  142. package/src/ssr/IslandError.tsx +33 -0
  143. package/src/ssr/Layout.tsx +3 -0
  144. package/src/ssr/LayoutHelp.tsx +4 -2
  145. package/src/ssr/RailApps.island.tsx +9 -2
  146. package/src/ssr/app-badges.ts +93 -0
  147. package/src/ssr/platform-messages.ts +1 -1
  148. package/src/ssr/rail-messages.ts +4 -0
  149. package/src/ssr/rail-navigation.ts +2 -0
  150. package/src/styles/effects.css +38 -17
  151. package/src/styles/file-chooser.css +25 -0
  152. package/src/styles/tokens.css +2 -0
  153. package/src/styles/utilities-markdown-editor.css +4 -39
  154. package/src/styles/utilities-markdown-table.css +14 -17
  155. package/src/styles/utilities-navigation.css +41 -0
@@ -23,7 +23,7 @@ import { taskListExtension } from "./extensions/task-list";
23
23
  type MarkdownProfile = "content" | "help";
24
24
  type LinkStyle = "widget" | "plain";
25
25
 
26
- const createMarked = (profile: MarkdownProfile, links: LinkStyle, infoBlocks: MarkedExtension) => {
26
+ const createMarked = (profile: MarkdownProfile, links: LinkStyle, infoBlocks: MarkedExtension, locale?: string) => {
27
27
  const marked = new Marked();
28
28
 
29
29
  marked.use({
@@ -37,7 +37,8 @@ const createMarked = (profile: MarkdownProfile, links: LinkStyle, infoBlocks: Ma
37
37
  marked.use(taskListExtension());
38
38
  marked.use(tablesExtension());
39
39
  // Plain links keep marked's own renderer: an anchor around the link text.
40
- if (links === "widget") marked.use(linksExtension({ internalTarget: profile === "help" ? "_self" : "_blank" }));
40
+ // Cloud links are calm reference pills and quiet web links from `@k2b/ui`.
41
+ if (links === "widget") marked.use(linksExtension({ internalTarget: profile === "help" ? "_self" : "_blank", locale }));
41
42
  marked.use(imagesExtension());
42
43
  marked.use(katexExtension());
43
44
  marked.use(codeExtension());
@@ -58,7 +59,7 @@ const markedFor = (profile: MarkdownProfile, links: LinkStyle = "widget", locale
58
59
  const byStyle = instances.get(infoBlocks) ?? new Map<string, Marked>();
59
60
  instances.set(infoBlocks, byStyle);
60
61
  const key = `${profile}:${links}`;
61
- const instance = byStyle.get(key) ?? createMarked(profile, links, infoBlocks);
62
+ const instance = byStyle.get(key) ?? createMarked(profile, links, infoBlocks, locale);
62
63
  byStyle.set(key, instance);
63
64
  return instance;
64
65
  };
@@ -66,7 +67,7 @@ const markedFor = (profile: MarkdownProfile, links: LinkStyle = "widget", locale
66
67
  const marked = markedFor("content");
67
68
 
68
69
  export type MarkdownRenderOptions = {
69
- /** `"plain"` renders each link as an ordinary anchor around its text, for HTML read outside Cloud such as email. */
70
+ /** `"plain"` renders each link as an ordinary anchor around its text, for HTML read outside Cloud such as email. The default renders references to Cloud content as calm pills and web links as quiet text links. */
70
71
  links?: LinkStyle;
71
72
  /** Request locale for the screen-reader names of untitled info blocks. Defaults to English on the server and the page language in the browser. */
72
73
  locale?: string;
@@ -111,7 +112,15 @@ const sanitizeRenderedHtml = (html: string): string =>
111
112
  ],
112
113
  allowedAttributes: {
113
114
  "*": ["aria-hidden", "aria-label", "class", "data-help-icon", "data-tone", "id", "title"],
114
- a: ["href", "name", "rel", "target", "title"],
115
+ a: [
116
+ "href",
117
+ "name",
118
+ "rel",
119
+ "target",
120
+ "title",
121
+ { name: "data-link", multiple: false, values: ["web", "mail"] },
122
+ { name: "data-reference", multiple: false, values: ["pdf", "image", "design", "file", "note", "heading", "task", "page"] },
123
+ ],
115
124
  annotation: ["encoding"],
116
125
  aside: [{ name: "role", multiple: false, values: ["note"] }],
117
126
  code: ["class"],
@@ -27,10 +27,3 @@ export const IMAGE_STYLES = {
27
27
  img: "block max-h-[400px] rounded border border-gray-200 dark:border-gray-700",
28
28
  caption: "text-sm text-gray-500 dark:text-gray-400 mt-2 italic",
29
29
  } as const;
30
-
31
- /** Link widget styles */
32
- export const LINK_STYLES = {
33
- link: "md-link-widget",
34
- label: "md-link-label",
35
- icon: "md-link-icon",
36
- } as const;
@@ -9,7 +9,8 @@ export const normalizeRedirectTo = (value: string | null | undefined): string |
9
9
 
10
10
  try {
11
11
  const url = new URL(trimmed, REDIRECT_BASE);
12
- if (url.origin !== REDIRECT_BASE) return undefined;
12
+ // Dot segments can resolve to `//host`, which a browser reads as another host.
13
+ if (url.origin !== REDIRECT_BASE || url.pathname.startsWith("//")) return undefined;
13
14
  return `${url.pathname}${url.search}${url.hash}`;
14
15
  } catch {
15
16
  return undefined;
@@ -1,10 +1,24 @@
1
- import { Liquid } from "liquidjs";
1
+ import {
2
+ AssertionError,
3
+ Context,
4
+ defaultOperators,
5
+ type Emitter,
6
+ type FilterImplOptions,
7
+ Liquid,
8
+ LiquidError,
9
+ type Operators,
10
+ toValue,
11
+ toValueSync,
12
+ } from "liquidjs";
2
13
 
3
14
  const TEMPLATE_MAX_BYTES = 200_000;
4
15
  const RENDER_MAX_BYTES = 300_000;
16
+ // Synchronous rendering blocks every request in the app process. Legitimate templates
17
+ // take milliseconds; keep this well below normal request latency budgets.
18
+ const RENDER_TIMEOUT_MS = 1_000;
5
19
 
6
20
  type LiquidEngine = Liquid;
7
- export type LiquidTemplateErrorReason = "render_too_large";
21
+ export type LiquidTemplateErrorReason = "render_too_large" | "render_timeout" | "render_memory_limit";
8
22
 
9
23
  export class LiquidTemplateError extends Error {
10
24
  constructor(
@@ -22,6 +36,7 @@ export type LiquidTemplateOptions = {
22
36
  escapeOutput?: boolean | ((value: unknown) => string);
23
37
  templateMaxBytes?: number;
24
38
  renderMaxBytes?: number;
39
+ renderTimeoutMs?: number;
25
40
  memoryLimit?: number;
26
41
  };
27
42
 
@@ -52,6 +67,35 @@ const TEMPLATE_TAG_RE = /{%-?\s*([A-Za-z_][A-Za-z0-9_]*)\b/g;
52
67
 
53
68
  const byteLength = (value: string): number => new TextEncoder().encode(value).byteLength;
54
69
 
70
+ class BoundedEmitter implements Emitter {
71
+ buffer = "";
72
+ private bytes = 0;
73
+ private lastCodeUnit = 0;
74
+
75
+ constructor(private readonly maxBytes: number) {}
76
+
77
+ write(input: unknown): void {
78
+ const value: unknown = toValue(input);
79
+ // Match LiquidJS's emitter: nil is empty, arrays concatenate their elements.
80
+ if (value === null || value === undefined) return;
81
+ if (Array.isArray(value)) {
82
+ for (const item of value) this.write(item);
83
+ return;
84
+ }
85
+ const chunk = String(value);
86
+ if (!chunk) return;
87
+ const last = this.lastCodeUnit;
88
+ const first = chunk.charCodeAt(0);
89
+ // A surrogate pair split across writes encodes as four bytes, rather than six.
90
+ const paired = last >= 0xd800 && last <= 0xdbff && first >= 0xdc00 && first <= 0xdfff;
91
+ const bytes = this.bytes + byteLength(chunk) - (paired ? 2 : 0);
92
+ if (bytes > this.maxBytes) throw new LiquidTemplateError("render_too_large", "Rendered template is too large");
93
+ this.bytes = bytes;
94
+ this.lastCodeUnit = chunk.charCodeAt(chunk.length - 1);
95
+ this.buffer += chunk;
96
+ }
97
+ }
98
+
55
99
  export const escapeTemplateOutput = (value: unknown): string =>
56
100
  String(value).replace(/[&<>"'`=/]/g, (char) => {
57
101
  switch (char) {
@@ -76,16 +120,34 @@ export const escapeTemplateOutput = (value: unknown): string =>
76
120
  }
77
121
  });
78
122
 
123
+ const expandedTextLength = (input: unknown): number => {
124
+ const value: unknown = toValue(input);
125
+ if (typeof value === "string") return value.length;
126
+ if (Array.isArray(value)) return value.reduce((length: number, item: unknown) => length + expandedTextLength(item), 0);
127
+ if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") return String(value).length;
128
+ return 0;
129
+ };
130
+
79
131
  const createEngine = (options: LiquidTemplateOptions = {}) => {
80
132
  const outputEscape =
81
133
  typeof options.escapeOutput === "function" ? options.escapeOutput : options.escapeOutput === false ? undefined : escapeTemplateOutput;
134
+ const operators: Operators = {};
135
+ for (const [name, handler] of Object.entries(defaultOperators)) {
136
+ operators[name] = (...args: [unknown, Context] | [unknown, unknown, Context]): boolean => {
137
+ const result: boolean = Reflect.apply(handler, undefined, args);
138
+ const context = args[args.length - 1];
139
+ if (context instanceof Context) context.renderLimit.check(performance.now());
140
+ return result;
141
+ };
142
+ }
82
143
  const engine = new Liquid({
144
+ operators,
83
145
  strictVariables: true,
84
146
  strictFilters: true,
85
147
  ownPropertyOnly: true,
86
148
  ...(outputEscape ? { outputEscape } : {}),
87
149
  parseLimit: options.templateMaxBytes ?? TEMPLATE_MAX_BYTES,
88
- renderLimit: options.renderMaxBytes ?? RENDER_MAX_BYTES,
150
+ renderLimit: options.renderTimeoutMs ?? RENDER_TIMEOUT_MS,
89
151
  memoryLimit: options.memoryLimit ?? 2_000_000,
90
152
  cache: false,
91
153
  dynamicPartials: false,
@@ -94,7 +156,42 @@ const createEngine = (options: LiquidTemplateOptions = {}) => {
94
156
  partials: [],
95
157
  });
96
158
 
159
+ const renderTemplates = engine.renderer.renderTemplates.bind(engine.renderer);
160
+ engine.renderer.renderTemplates = function* (templates, context, emitter) {
161
+ // Capture renders without an emitter; bound its intermediate buffer as well.
162
+ const result = yield* renderTemplates(
163
+ templates,
164
+ context,
165
+ emitter ?? new BoundedEmitter(Math.max(options.renderMaxBytes ?? RENDER_MAX_BYTES, RENDER_MAX_BYTES)),
166
+ );
167
+ // LiquidJS checks before templates and every loop body (even an empty one).
168
+ // Check after them too, so a slow final filter cannot return over budget.
169
+ context.renderLimit.check(performance.now());
170
+ return result;
171
+ };
172
+
173
+ for (const name of ["push", "concat", "unshift", "map"]) {
174
+ const filter = engine.filters[name];
175
+ if (!filter) continue;
176
+ const handler = typeof filter === "function" ? filter : filter.handler;
177
+ const bounded: FilterImplOptions = function* (...args: [unknown, ...unknown[]]): Generator<unknown, unknown, unknown> {
178
+ const result: unknown = yield handler.apply(this, args);
179
+ // Charge added text only; charging the left array again would compound.
180
+ this.context.memoryLimit.use(expandedTextLength(name === "map" ? result : args[1]));
181
+ return result;
182
+ };
183
+ engine.registerFilter(name, typeof filter === "function" ? bounded : { ...filter, handler: bounded });
184
+ }
97
185
  for (const [name, filter] of Object.entries(options.filters ?? {})) engine.registerFilter(name, filter);
186
+ for (const [name, filter] of Object.entries(engine.filters)) {
187
+ const handler = typeof filter === "function" ? filter : filter.handler;
188
+ const timed: FilterImplOptions = function* (...args: [unknown, ...unknown[]]): Generator<unknown, unknown, unknown> {
189
+ const result: unknown = yield handler.apply(this, args);
190
+ this.context.renderLimit.check(performance.now());
191
+ return result;
192
+ };
193
+ engine.registerFilter(name, typeof filter === "function" ? timed : { ...filter, handler: timed });
194
+ }
98
195
  return engine;
99
196
  };
100
197
 
@@ -126,11 +223,27 @@ export const validateLiquidTemplate = (
126
223
  export const renderLiquidTemplate = (template: string, data: Record<string, unknown>, options: LiquidTemplateOptions = {}): string => {
127
224
  const valid = validateLiquidTemplate(template, options);
128
225
  if (!valid.ok) throw new Error(valid.error);
129
- const rendered = engineFor(options).parseAndRenderSync(template, data);
130
- if (byteLength(rendered) > (options.renderMaxBytes ?? RENDER_MAX_BYTES)) {
131
- throw new LiquidTemplateError("render_too_large", "Rendered template is too large");
226
+ const engine = engineFor(options);
227
+ const context = new Context(data, engine.options, { sync: true }, { liquid: engine });
228
+ const emitter = new BoundedEmitter(options.renderMaxBytes ?? RENDER_MAX_BYTES);
229
+ try {
230
+ toValueSync(engine.renderer.renderTemplates(engine.parse(template), context, emitter));
231
+ return emitter.buffer;
232
+ } catch (error) {
233
+ // LiquidJS wraps emitter and limiter failures in RenderError.originalError.
234
+ let original = error;
235
+ while (original instanceof LiquidError && original.originalError) original = original.originalError;
236
+ if (original instanceof LiquidTemplateError) throw original;
237
+ if (original instanceof AssertionError) {
238
+ if (original.message === "template render limit exceeded") {
239
+ throw new LiquidTemplateError("render_timeout", "Template rendering exceeded its time budget");
240
+ }
241
+ if (original.message === "memory alloc limit exceeded") {
242
+ throw new LiquidTemplateError("render_memory_limit", "Template rendering exceeded its memory limit");
243
+ }
244
+ }
245
+ throw error;
132
246
  }
133
- return rendered;
134
247
  };
135
248
 
136
249
  export const liquidTemplateVariables = (template: string, options: LiquidTemplateOptions = {}): string[] => {
@@ -0,0 +1,30 @@
1
+ import { useLocale } from "@k2b/ui";
2
+ import { Show } from "solid-js";
3
+ import { appBadgeCount } from "./app-badges";
4
+ import { railMessages } from "./rail-messages";
5
+
6
+ /** What a screen reader hears after the app name; empty without a count. Says no more than the "99+" shown. */
7
+ export const useAppBadgeDescription = (endpoint: () => string | undefined) => {
8
+ const locale = useLocale();
9
+ return () => {
10
+ const count = appBadgeCount(endpoint());
11
+ const t = railMessages.resolve([locale()]).t;
12
+ return count > 99 ? t.badgeMany : count > 0 ? t.badge({ count }) : "";
13
+ };
14
+ };
15
+
16
+ /**
17
+ * Count on the corner of an app icon. It is positioned over the icon and never
18
+ * takes layout space, so appearing, growing to "99+", or clearing moves
19
+ * nothing. Decorative: the link carries the description.
20
+ */
21
+ export function AppBadge(props: { endpoint?: string }) {
22
+ const count = () => appBadgeCount(props.endpoint);
23
+ return (
24
+ <Show when={count() > 0}>
25
+ <span class="cloud-app-badge" aria-hidden="true">
26
+ {count() > 99 ? "99+" : count()}
27
+ </span>
28
+ </Show>
29
+ );
30
+ }
@@ -3,6 +3,7 @@ import { IconButton, ScrollArea, TextInput, useLocale } from "@k2b/ui";
3
3
  import { createSignal, For, type JSX, Show } from "solid-js";
4
4
  import { Dynamic } from "solid-js/web";
5
5
  import type { CloudTheme } from "../shared/theme";
6
+ import { AppBadge, useAppBadgeDescription } from "./AppBadge";
6
7
  import { MobileProfileActions } from "./MobileProfileActions";
7
8
  import { platformMessages } from "./platform-messages";
8
9
  import { openRailEditor } from "./RailEditor";
@@ -17,6 +18,8 @@ export type AppLaunchpadApp = {
17
18
  href: string;
18
19
  description?: string;
19
20
  accent?: string;
21
+ /** The app's `nav.badge` endpoint. */
22
+ badge?: string;
20
23
  };
21
24
 
22
25
  export type AppLaunchpadLegalLink = {
@@ -119,39 +122,57 @@ export const AppLaunchpadPanel = (
119
122
  <section class="mb-5" aria-label={t().shortcuts}>
120
123
  <div class="flex flex-wrap justify-center gap-3">
121
124
  <For each={shortcuts()}>
122
- {(shortcut) => (
123
- <a
124
- onClick={follow}
125
- href={shortcut.href}
126
- class="group flex w-16 min-w-0 flex-col items-center gap-1 rounded-lg p-1 text-center focus-ui"
127
- >
128
- <span class="grid h-9 w-9 place-items-center rounded-xl bg-[var(--ui-hover)] text-lg transition-colors group-hover:bg-[var(--ui-active)]">
129
- <i class={shortcut.iconClass} aria-hidden="true" />
130
- </span>
131
- <span class="max-w-full truncate text-[11px] font-medium text-primary dark:text-white">{shortcut.label}</span>
132
- </a>
133
- )}
125
+ {(shortcut) => {
126
+ const badge = useAppBadgeDescription(() => shortcut.badge);
127
+ return (
128
+ <a
129
+ onClick={follow}
130
+ href={shortcut.href}
131
+ class="group flex w-16 min-w-0 flex-col items-center gap-1 rounded-lg p-1 text-center focus-ui"
132
+ >
133
+ <span class="relative grid h-9 w-9 place-items-center rounded-xl bg-[var(--ui-hover)] text-lg transition-colors group-hover:bg-[var(--ui-active)]">
134
+ <i class={shortcut.iconClass} aria-hidden="true" />
135
+ <AppBadge endpoint={shortcut.badge} />
136
+ </span>
137
+ <span class="max-w-full truncate text-[11px] font-medium text-primary dark:text-white">
138
+ {shortcut.label}
139
+ <Show when={badge()}>
140
+ <span class="sr-only">, {badge()}</span>
141
+ </Show>
142
+ </span>
143
+ </a>
144
+ );
145
+ }}
134
146
  </For>
135
147
  </div>
136
148
  </section>
137
149
  </Show>
138
150
  <div class="launchpad-apps-grid flex flex-wrap justify-center gap-x-4 gap-y-4 sm:gap-x-7 sm:gap-y-6">
139
151
  <For each={apps()}>
140
- {(app) => (
141
- <a
142
- onClick={follow}
143
- href={app.href}
144
- class="launchpad-app group flex w-[4.75rem] min-w-0 flex-col items-center gap-1.5 rounded-2xl p-1 text-center focus-ui sm:w-[6.25rem] sm:gap-2 sm:p-2"
145
- >
146
- <span
147
- class="app-icon grid h-12 w-12 place-items-center rounded-[0.95rem] text-[1.25rem] sm:h-16 sm:w-16 sm:rounded-[1.25rem] sm:text-[1.7rem]"
148
- style={appIconStyle(app)}
152
+ {(app) => {
153
+ const badge = useAppBadgeDescription(() => app.badge);
154
+ return (
155
+ <a
156
+ onClick={follow}
157
+ href={app.href}
158
+ class="launchpad-app group flex w-[4.75rem] min-w-0 flex-col items-center gap-1.5 rounded-2xl p-1 text-center focus-ui sm:w-[6.25rem] sm:gap-2 sm:p-2"
149
159
  >
150
- <i class={app.iconClass} />
151
- </span>
152
- <span class="max-w-full truncate text-[11px] font-medium text-primary sm:text-xs dark:text-white">{app.label}</span>
153
- </a>
154
- )}
160
+ <span
161
+ class="app-icon relative grid h-12 w-12 place-items-center rounded-[0.95rem] text-[1.25rem] sm:h-16 sm:w-16 sm:rounded-[1.25rem] sm:text-[1.7rem]"
162
+ style={appIconStyle(app)}
163
+ >
164
+ <i class={app.iconClass} />
165
+ <AppBadge endpoint={app.badge} />
166
+ </span>
167
+ <span class="max-w-full truncate text-[11px] font-medium text-primary sm:text-xs dark:text-white">
168
+ {app.label}
169
+ <Show when={badge()}>
170
+ <span class="sr-only">, {badge()}</span>
171
+ </Show>
172
+ </span>
173
+ </a>
174
+ );
175
+ }}
155
176
  </For>
156
177
  </div>
157
178
  <Show when={!apps().length && !shortcuts().length}>
@@ -102,7 +102,7 @@ export default function GlobalAnnouncements(props: Props) {
102
102
  <MarkdownView
103
103
  trustedHtml={banner.bodyHtml}
104
104
  headingScale="compact"
105
- class="max-h-36 overflow-y-auto overscroll-contain pr-1 [&_p]:my-0"
105
+ class="-mx-2 max-h-36 overflow-y-auto overscroll-contain pl-2 pr-3 [&_p]:my-0"
106
106
  />
107
107
  </NoticeCard>
108
108
  <IconButton size="sm" label={t().dismissBanner} onClick={() => dismissBanner(banner.version)}>
@@ -0,0 +1,33 @@
1
+ import type { IslandErrorProps } from "@k2b/ssr";
2
+ import { i18n } from "@k2b/stdlib";
3
+ import { Button, Placeholder, useLocale } from "@k2b/ui";
4
+
5
+ const messages = i18n.define({
6
+ baseLocale: "en",
7
+ messages: {
8
+ en: { title: "This section could not be displayed.", retry: "Try again" },
9
+ de: { title: "Dieser Bereich konnte nicht angezeigt werden.", retry: "Erneut versuchen" },
10
+ },
11
+ });
12
+
13
+ /**
14
+ * Takes the place of an island or client component whose rendering failed.
15
+ * `defineApp` hands it to @k2b/ssr as `errorFallback`, so every island of every
16
+ * app gets it; the locale comes from `<html lang>`. "Try again" remounts the
17
+ * island with its original props.
18
+ */
19
+ export default function IslandError(props: IslandErrorProps) {
20
+ const locale = useLocale();
21
+ const t = () => messages.resolve([locale()]).t;
22
+ return (
23
+ <Placeholder
24
+ state="error"
25
+ title={t().title}
26
+ action={
27
+ <Button variant="secondary" onClick={() => props.reset()}>
28
+ {t().retry}
29
+ </Button>
30
+ }
31
+ />
32
+ );
33
+ }
@@ -61,6 +61,7 @@ function buildNavLinks(
61
61
  match: resolveNavMatch(app) ?? app.nav.href.split("?")[0] ?? app.nav.href,
62
62
  description: app.description,
63
63
  accent: app.appearance?.accent,
64
+ badge: app.nav.badge,
64
65
  } satisfies LayoutAppLink,
65
66
  }));
66
67
  const primary = links.filter((entry) => entry.section === "primary").map((entry) => entry.link);
@@ -75,6 +76,7 @@ function buildNavLinks(
75
76
  match: "/admin",
76
77
  description: t.platformAdministration,
77
78
  accent: undefined,
79
+ badge: undefined,
78
80
  });
79
81
  }
80
82
  return { primary, more };
@@ -166,6 +168,7 @@ export default function Layout(props: LayoutProps) {
166
168
  href: app.href,
167
169
  description: app.description,
168
170
  accent: app.accent,
171
+ badge: app.badge,
169
172
  }));
170
173
  const searchLinks = navigationSearchItems(
171
174
  runtime.apps,
@@ -95,11 +95,13 @@ const registerTopic = (topic: HelpTopic) => {
95
95
  /** Register an app's server-prepared Markdown manifest with the shared help UI. */
96
96
  export function LayoutHelpDocuments(props: LayoutHelpDocumentsProps) {
97
97
  onMount(() => {
98
- window.__cloudLayoutHelpPageBase = props.pageBase;
98
+ // Cleanup runs while this unmounts, when a parent `<Show>` may already have dropped the props' source.
99
+ const pageBase = props.pageBase;
100
+ window.__cloudLayoutHelpPageBase = pageBase;
99
101
  const disposers = props.documents.map((document) => registerTopic({ ...document, kind: "document" }));
100
102
  onCleanup(() => {
101
103
  disposers.forEach((dispose) => dispose());
102
- if (window.__cloudLayoutHelpPageBase === props.pageBase) delete window.__cloudLayoutHelpPageBase;
104
+ if (window.__cloudLayoutHelpPageBase === pageBase) delete window.__cloudLayoutHelpPageBase;
103
105
  });
104
106
  });
105
107
  return null;
@@ -1,25 +1,29 @@
1
1
  import { ScrollArea, Tooltip, useLocale } from "@k2b/ui";
2
2
  import { createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js";
3
3
  import type { RailSnapshot } from "../contracts/rail-preferences";
4
+ import { AppBadge, useAppBadgeDescription } from "./AppBadge";
4
5
  import { appAccentStyle } from "./app-appearance";
6
+ import { watchAppBadges } from "./app-badges";
5
7
  import { RAIL_PREFERENCES_EVENT, readRailContext } from "./rail-context";
6
8
  import { projectRailNavigation, type RailApp, type RailLink, railLinkActive } from "./rail-navigation";
7
9
 
8
10
  function RailIcon(props: { link: RailLink; currentPath: string }) {
9
11
  let target: HTMLAnchorElement | undefined;
10
12
  const active = () => railLinkActive(props.link, props.currentPath);
13
+ const badge = useAppBadgeDescription(() => props.link.badge);
11
14
  return (
12
15
  <>
13
16
  <a
14
17
  ref={target}
15
18
  href={props.link.href}
16
- class={`rail-item shrink-0 ${active() ? "rail-item-active" : ""}`}
17
- aria-label={props.link.label}
19
+ class={`rail-item relative shrink-0 ${active() ? "rail-item-active" : ""}`}
20
+ aria-label={badge() ? `${props.link.label}, ${badge()}` : props.link.label}
18
21
  aria-current={active() ? "page" : undefined}
19
22
  style={appAccentStyle(props.link.accent)}
20
23
  >
21
24
  <i class={`${props.link.iconClass} text-base`} aria-hidden="true" />
22
25
  <span class="sr-only">{props.link.label}</span>
26
+ <AppBadge endpoint={props.link.badge} />
23
27
  </a>
24
28
  <Tooltip target={() => target} content={props.link.label} placement="right" delay={0} />
25
29
  </>
@@ -36,10 +40,13 @@ export default function RailApps(props: { apps: RailApp[]; settings: RailSnapsho
36
40
  const syncLocation = () => setCurrentPath(`${window.location.pathname}${window.location.search}${window.location.hash}`);
37
41
  sync();
38
42
  syncLocation();
43
+ // Every visible app, not only the rail's: the app grid shows the same counts.
44
+ const stopBadges = watchAppBadges(props.apps.flatMap((app) => (app.badge ? [app.badge] : [])));
39
45
  window.addEventListener(RAIL_PREFERENCES_EVENT, sync);
40
46
  window.addEventListener("popstate", syncLocation);
41
47
  window.addEventListener("hashchange", syncLocation);
42
48
  onCleanup(() => {
49
+ stopBadges();
43
50
  window.removeEventListener(RAIL_PREFERENCES_EVENT, sync);
44
51
  window.removeEventListener("popstate", syncLocation);
45
52
  window.removeEventListener("hashchange", syncLocation);
@@ -0,0 +1,93 @@
1
+ import { createSignal } from "solid-js";
2
+ import { APP_BADGES_REFRESH_EVENT } from "../browser/app-badges";
3
+
4
+ /**
5
+ * Shortest time between two scheduled reads in one tab, and the longest one
6
+ * read may take. Badges are a hint to open an app, not a live view, so one
7
+ * read per minute keeps them current while someone works elsewhere without a
8
+ * steady stream of requests per tab.
9
+ */
10
+ export const APP_BADGE_INTERVAL_MS = 60_000;
11
+
12
+ // Keyed by endpoint, so a pinned app shortcut and the app's own entry share one
13
+ // count. Only the browser ever writes it; server renders always see no badge.
14
+ const [counts, setCounts] = createSignal<ReadonlyMap<string, number>>(new Map());
15
+
16
+ /** The count for a declared `nav.badge` endpoint; 0 until the browser has read it. */
17
+ export const appBadgeCount = (endpoint: string | undefined): number => (endpoint ? (counts().get(endpoint) ?? 0) : 0);
18
+
19
+ /** A positive safe integer from an `AppNavBadge` body, else 0. */
20
+ export const parseAppBadge = (body: unknown): number => {
21
+ if (typeof body !== "object" || body === null || !("count" in body)) return 0;
22
+ const count = body.count;
23
+ return typeof count === "number" && Number.isSafeInteger(count) && count > 0 ? count : 0;
24
+ };
25
+
26
+ const readBadge = async (endpoint: string, signal: AbortSignal): Promise<number> => {
27
+ try {
28
+ const response = await fetch(endpoint, { credentials: "same-origin", headers: { accept: "application/json" }, signal });
29
+ return response.status === 200 ? parseAppBadge(await response.json()) : 0;
30
+ } catch {
31
+ // An unreachable or failing app shows no badge rather than a stale one.
32
+ return 0;
33
+ }
34
+ };
35
+
36
+ let watching = false;
37
+
38
+ /**
39
+ * Reads the badge endpoints after hydration, every interval while the tab is
40
+ * visible, when a hidden tab becomes visible again after the interval, and on
41
+ * `refreshAppBadges()`. A hidden tab sends nothing and keeps its schedule.
42
+ * Each endpoint has at most one read in flight, and its count shows as soon as
43
+ * that read settles, so a slow app delays only its own badge. A refresh while
44
+ * a read runs queues one more read after it instead of another request, and a
45
+ * read still running after the interval gives up. Returns a disposer.
46
+ */
47
+ export const watchAppBadges = (endpoints: readonly string[]): (() => void) => {
48
+ const unique = [...new Set(endpoints)];
49
+ if (typeof window === "undefined" || unique.length === 0 || watching) return () => {};
50
+ watching = true;
51
+ const stopped = new AbortController();
52
+ // Endpoints with a read in flight, mapped to whether one more read waits for it.
53
+ const running = new Map<string, boolean>();
54
+ let timer: ReturnType<typeof setTimeout> | undefined;
55
+ let last = Number.NEGATIVE_INFINITY;
56
+
57
+ const read = (endpoint: string) => {
58
+ if (running.has(endpoint)) {
59
+ running.set(endpoint, true);
60
+ return;
61
+ }
62
+ running.set(endpoint, false);
63
+ void readBadge(endpoint, AbortSignal.any([stopped.signal, AbortSignal.timeout(APP_BADGE_INTERVAL_MS)])).then((count) => {
64
+ if (stopped.signal.aborted) return;
65
+ setCounts((previous) => new Map(previous).set(endpoint, count));
66
+ const queued = running.get(endpoint);
67
+ running.delete(endpoint);
68
+ if (queued && document.visibilityState !== "hidden") read(endpoint);
69
+ });
70
+ };
71
+ const refresh = () => {
72
+ // Leave the pending read in place, so the tab still reads on time once it is visible again.
73
+ if (document.visibilityState === "hidden") return;
74
+ clearTimeout(timer);
75
+ last = Date.now();
76
+ timer = setTimeout(refresh, APP_BADGE_INTERVAL_MS);
77
+ for (const endpoint of unique) read(endpoint);
78
+ };
79
+ const resume = () => {
80
+ if (document.visibilityState !== "hidden" && Date.now() - last >= APP_BADGE_INTERVAL_MS) refresh();
81
+ };
82
+
83
+ document.addEventListener("visibilitychange", resume);
84
+ window.addEventListener(APP_BADGES_REFRESH_EVENT, refresh);
85
+ refresh();
86
+ return () => {
87
+ watching = false;
88
+ clearTimeout(timer);
89
+ stopped.abort();
90
+ document.removeEventListener("visibilitychange", resume);
91
+ window.removeEventListener(APP_BADGES_REFRESH_EVENT, refresh);
92
+ };
93
+ };
@@ -98,7 +98,7 @@ export const platformMessages = i18n.define({
98
98
  skills: "Skills",
99
99
  projects: "Projekte",
100
100
  aiUsage: "Nutzung",
101
- aiQuotas: "Assistant-Limits",
101
+ aiQuotas: "Assistenten-Limits",
102
102
  backgroundJobs: "Hintergrundaufgaben",
103
103
  apps: "Apps",
104
104
  appMenu: "Menü",
@@ -32,6 +32,8 @@ export const railMessages = i18n.define({
32
32
  saveFailed: "Could not save app bar settings. Your edits are still here.",
33
33
  conflict: "The app bar changed in another editor. Cancel and reopen this editor to load the latest settings.",
34
34
  invalid: "Check shortcut titles, links and icons. Each app can be pinned once; settings must fit within 16 KiB.",
35
+ badge: ({ count }: { count: number }) => `${count} new`,
36
+ badgeMany: "more than 99 new",
35
37
  },
36
38
  de: {
37
39
  managed: "Von der Administration bereitgestellt",
@@ -63,6 +65,8 @@ export const railMessages = i18n.define({
63
65
  conflict:
64
66
  "Die App-Leiste wurde in einem anderen Editor geändert. Brich ab und öffne den Editor erneut, um den aktuellen Stand zu laden.",
65
67
  invalid: "Prüfe Titel, Links und Icons. Jede App kann einmal angeheftet werden; die Einstellungen dürfen höchstens 16 KiB belegen.",
68
+ badge: ({ count }) => `${count} neu`,
69
+ badgeMany: "mehr als 99 neu",
66
70
  },
67
71
  },
68
72
  });
@@ -9,6 +9,8 @@ export type RailApp = {
9
9
  defaultVisible: boolean;
10
10
  description?: string;
11
11
  accent?: string;
12
+ /** The app's `nav.badge` endpoint. */
13
+ badge?: string;
12
14
  };
13
15
  export type RailLink = Omit<RailApp, "defaultVisible"> & { exact?: boolean };
14
16