@k2b/cloud 0.19.0 → 0.20.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@k2b/cloud",
3
- "version": "0.19.0",
3
+ "version": "0.20.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": {
@@ -93,23 +93,23 @@
93
93
  "@firecrawl/anydoc": "0.2.4",
94
94
  "@napi-rs/canvas": "1.0.9",
95
95
  "pdfjs-dist": "6.3.289",
96
- "@hono/standard-validator": "0.2.2",
97
- "@simplewebauthn/server": "14.0.2",
98
- "@tabler/icons": "3.47.0",
96
+ "@simplewebauthn/server": "14.0.3",
97
+ "@tabler/icons": "3.48.0",
99
98
  "@tailwindcss/typography": "0.5.20",
100
99
  "@k2b/nessi": "0.12.1",
101
100
  "@k2b/ssr": "0.14.0",
102
- "@k2b/ui": "0.9.0",
103
- "@k2b/stdlib": "0.26.0",
104
- "@k2b/sync": "6.5.0",
101
+ "@k2b/ui": "0.9.1",
102
+ "@k2b/stdlib": "0.27.0",
103
+ "@k2b/sync": "7.0.0",
104
+ "@nats-io/jetstream": "3.4.0",
105
105
  "@nats-io/transport-node": "3.4.0",
106
- "@modelcontextprotocol/sdk": "1.30.0",
106
+ "@modelcontextprotocol/sdk": "1.30.1",
107
107
  "bun-plugin-tailwind": "0.1.2",
108
- "hono-openapi": "1.3.1",
108
+ "hono-openapi": "1.3.3",
109
109
  "jose": "6.2.12",
110
- "katex": "0.18.7",
110
+ "katex": "0.18.9",
111
111
  "liquidjs": "10.29.0",
112
- "marked": "17.0.6",
112
+ "marked": "18.0.14",
113
113
  "mermaid": "12.0.0",
114
114
  "nodemailer": "10.0.10",
115
115
  "postcss": "8.5.28",
@@ -129,8 +129,8 @@
129
129
  "@babel/preset-typescript": "8.0.1",
130
130
  "@types/bun": "1.4.2",
131
131
  "babel-preset-solid": "1.9.15",
132
- "hono": "4.13.8",
133
- "playwright": "1.62.0",
132
+ "hono": "4.13.9",
133
+ "playwright": "1.63.0",
134
134
  "solid-js": "1.9.15",
135
135
  "typescript": "5.9.3",
136
136
  "zod": "4.6.5"
package/scripts/build.ts CHANGED
@@ -32,6 +32,7 @@ import { promisify } from "node:util";
32
32
  import { brotliCompress, gzip, constants as zlibConstants } from "node:zlib";
33
33
  import { Glob } from "bun";
34
34
  import tailwind from "bun-plugin-tailwind";
35
+ import type { AppCliModules } from "../src/contracts/app";
35
36
  import { writeAppFavicon } from "./app-favicon";
36
37
  import { buildBrowserPerformance } from "./browser-performance";
37
38
  import { buildPdfRenderer } from "./build-pdf-renderer";
@@ -181,7 +182,8 @@ if (existsSync(appAssets)) {
181
182
  // `cld` plugins: one self-contained module bundle plus references per module.
182
183
  // Imported after the app config so the framework sees the APP_DIR set above.
183
184
  const { buildCliPlugin, writeCliPlugin } = await import("../src/_internal/cli-plugins");
184
- for (const [name, declaration] of Object.entries(app?.meta.cli ?? {})) {
185
+ const cliModules: AppCliModules = app?.meta.cli ?? {};
186
+ for (const [name, declaration] of Object.entries(cliModules)) {
185
187
  const plugin = await buildCliPlugin({ appDir, appId, name, declaration, version });
186
188
  await writeCliPlugin(resolve(dist, "cli", name), plugin);
187
189
  }
@@ -68,10 +68,7 @@ Run "prepare", restart the application fleet while NATS stays up, then run "reco
68
68
  check(phase === "prepare" || phase === "recover", "Pass prepare or recover");
69
69
  const namespace = options.namespace?.trim() ?? "";
70
70
  check(/^cloud-recovery-smoke-[A-Za-z0-9_-]{6,60}$/.test(namespace), "Pass a unique --namespace cloud-recovery-smoke-<suffix>");
71
- const servers = (env.NATS_SERVERS ?? "nats://127.0.0.1:4222")
72
- .split(",")
73
- .map((value) => value.trim())
74
- .filter(Boolean);
71
+ const servers = env.NATS_SERVERS.length ? env.NATS_SERVERS : ["nats://127.0.0.1:4222"];
75
72
  const connection = await connect({ servers, name: `${APPLICATION}-${phase}`, ignoreClusterUpdates: true });
76
73
  const sync = createSync({ connection, namespace, application: APPLICATION, defaults: { replicas: env.SYNC_REPLICAS } });
77
74
  const job = sync.job<{ preparedAt: string }>({
@@ -48,6 +48,7 @@ import { requireInvocation } from "../server/middleware/invocation";
48
48
  import { matchedRouteTemplate, routeTemplate } from "../server/middleware/route-template";
49
49
  import { runtime as runtimeMiddleware } from "../server/middleware/runtime";
50
50
  import { preloadLayoutAnnouncements, settings as settingsMiddleware } from "../server/middleware/settings";
51
+ import { validationErrorResponse } from "../server/middleware/validator";
51
52
  import {
52
53
  capabilityInvocationOperation,
53
54
  searchInvocationOperation,
@@ -774,6 +775,7 @@ export const defineApp = <
774
775
  if (advertiseOpenapi) {
775
776
  const apiPrefix = opts.openapi!.replace(/\/openapi\.json$/, "") || "/";
776
777
  const spec = await generateSpecs(startOpts.openapi!, {
778
+ defaultValidationErrorResponse: validationErrorResponse,
777
779
  documentation: {
778
780
  info: {
779
781
  title: meta.name,
@@ -12,6 +12,7 @@ import { createSync, type Sync } from "@k2b/sync";
12
12
  import { env } from "../config/env";
13
13
  import { flushSyncTraceEvents, observeSyncEvent } from "../services/logging/trace";
14
14
  import { connectNats } from "./nats-connection";
15
+ import { withSyncBudgets } from "./sync-budget";
15
16
 
16
17
  let current: Sync | undefined;
17
18
  let starting = false;
@@ -64,7 +65,11 @@ export type ProcessSync = {
64
65
  stop: () => Promise<void>;
65
66
  };
66
67
 
67
- /** Connect NATS, create and bind the process Sync instance, and wait until it is ready. */
68
+ /**
69
+ * Connect NATS, create and bind the process Sync instance, and wait until it is
70
+ * ready. Existing streams of its jobs, queues, and topics take the byte
71
+ * limits they declare before first use (see `sync-budget.ts`).
72
+ */
68
73
  export const startProcessSync = async ({ application }: { application: string }): Promise<ProcessSync> => {
69
74
  if (!env.SYNC_NAMESPACE.trim()) {
70
75
  throw new Error(
@@ -78,13 +83,16 @@ export const startProcessSync = async ({ application }: { application: string })
78
83
  const connection = await connectNats({ name: `${application}@${hostname()}` });
79
84
  let sync: Sync | undefined;
80
85
  try {
81
- sync = createSync({
82
- connection,
83
- namespace: env.SYNC_NAMESPACE,
84
- application,
85
- defaults: { replicas: env.SYNC_REPLICAS },
86
- observe: (event) => observeSyncEvent(event, application),
87
- });
86
+ sync = withSyncBudgets(
87
+ createSync({
88
+ connection,
89
+ namespace: env.SYNC_NAMESPACE,
90
+ application,
91
+ defaults: { replicas: env.SYNC_REPLICAS },
92
+ observe: (event) => observeSyncEvent(event, application),
93
+ }),
94
+ { connection, namespace: env.SYNC_NAMESPACE },
95
+ );
88
96
  bindProcessSync(sync);
89
97
  await sync.ready();
90
98
  } catch (error) {
@@ -28,6 +28,9 @@ type SsrHandler<E extends Env, T extends object> = (context: Context<E & PageEnv
28
28
  * (such as the request locale for `<html lang>`) are present on every SSR
29
29
  * page without per-route plumbing. Redirects and other passthrough Responses
30
30
  * skip it.
31
+ *
32
+ * Rendered documents default to `Cache-Control: private, no-store` unless the
33
+ * handler or a middleware already chose a policy.
31
34
  */
32
35
  export const createStatusPreservingSsrHandler = <T extends object>(
33
36
  html: HtmlFn<T>,
@@ -55,7 +58,12 @@ export const createStatusPreservingSsrHandler = <T extends object>(
55
58
  response.headers.forEach((value, key) => {
56
59
  headers[key] = value;
57
60
  });
58
- return context.newResponse(response.body, status as StatusCode, headers);
61
+ const document = context.newResponse(response.body, status as StatusCode, headers);
62
+ // A rendered document carries the signed-in user's state. Without an
63
+ // explicit policy, a browser may show it again on Back from its HTTP
64
+ // cache, even after sign-out. Handlers that set their own policy keep it.
65
+ if (!document.headers.has("Cache-Control")) document.headers.set("Cache-Control", "private, no-store");
66
+ return document;
59
67
  });
60
68
  };
61
69
  };
@@ -0,0 +1,306 @@
1
+ /**
2
+ * Brings existing JetStream streams to the byte limits that the Sync jobs,
3
+ * queues, and topics of one Cloud process declare.
4
+ *
5
+ * JetStream reserves a stream's whole `max_bytes` on every replica as soon as
6
+ * the stream exists. @k2b/sync 7 declares small limits: a job or queue without
7
+ * `retention` holds 256 messages at its payload limit, and `deadLetterRetention`
8
+ * sizes dead letters apart from the work or event stream. Sync never
9
+ * reconfigures an existing stream, though. A stream created with other limits,
10
+ * such as the 1 GiB that Sync 6 gave every job and queue without `retention`,
11
+ * fails the declaration with `ResourceDriftError` on `max_bytes`.
12
+ *
13
+ * Before a job, queue, or topic is first used, this module therefore changes
14
+ * the byte limit of such a stream in place: both streams of a job or queue,
15
+ * and the dead-letter stream of a topic. A stream that holds more than its new
16
+ * limit would lose its oldest messages; it keeps its limit instead:
17
+ *
18
+ * - a job or queue is declared with the stream's current limit, so its work
19
+ * stays usable, and a later start applies the new limit;
20
+ * - a topic is declared synchronously, before its streams can be inspected,
21
+ * so its declaration keeps reporting the drift until the stream holds less.
22
+ */
23
+ import {
24
+ type DeadLetterStore,
25
+ type Job,
26
+ type JobConfig,
27
+ type Queue,
28
+ type QueueConfig,
29
+ ResourceDriftError,
30
+ type Sync,
31
+ type Topic,
32
+ type TopicConfig,
33
+ } from "@k2b/sync";
34
+ import { jetstreamManager, RetentionPolicy, type StreamInfo } from "@nats-io/jetstream";
35
+ import type { NatsConnection } from "@nats-io/transport-node";
36
+ import { logger } from "../services/logging";
37
+
38
+ /** Sync 7's limits for a job or queue declared without `retention`, which @k2b/sync does not export. */
39
+ const SYNC_PAYLOAD_BYTES = 128 * 1024;
40
+ const SYNC_DEAD_LETTER_HEADROOM_BYTES = 4096;
41
+ const SYNC_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
42
+
43
+ /** Byte limit Sync 7 gives a job or queue without `retention`: 256 messages at its payload limit, at most 1 GiB. */
44
+ export const syncDefaultMaxBytes = (maxPayloadBytes = SYNC_PAYLOAD_BYTES): number =>
45
+ Math.min(1024 ** 3, 256 * (maxPayloadBytes + SYNC_DEAD_LETTER_HEADROOM_BYTES));
46
+
47
+ const log = logger("sync:budget");
48
+
49
+ type Kind = "job" | "queue" | "topic";
50
+ type Limits = { work: number; deadLetters: number };
51
+
52
+ /** Memoizes a promise, forgetting a rejection so the next call retries. */
53
+ const once = <T>(create: () => Promise<T>): (() => Promise<T>) => {
54
+ let pending: Promise<T> | undefined;
55
+ return () =>
56
+ (pending ??= create().catch((error: unknown) => {
57
+ pending = undefined;
58
+ throw error;
59
+ }));
60
+ };
61
+
62
+ /** The stream and declared byte limit when Sync refused a declaration only for an existing stream's byte limit. */
63
+ const byteLimitDrift = (error: unknown): { stream: string; maxBytes: number } | undefined => {
64
+ if (!(error instanceof ResourceDriftError) || error.differences.length !== 1) return undefined;
65
+ const [difference] = error.differences;
66
+ if (difference?.field !== "max_bytes" || typeof difference.declared !== "number") return undefined;
67
+ return { stream: error.resource, maxBytes: difference.declared };
68
+ };
69
+
70
+ /** Byte limits a job or queue declares for its work and dead-letter streams. */
71
+ const declaredLimits = (config: QueueConfig | JobConfig): Limits => {
72
+ const work = config.retention?.maxBytes ?? syncDefaultMaxBytes(config.maxPayloadBytes);
73
+ return { work, deadLetters: config.deadLetterRetention?.maxBytes ?? work };
74
+ };
75
+
76
+ /** The declaration with the given limits, or unchanged when they are its own. */
77
+ const withLimits = <Config extends QueueConfig | JobConfig>(config: Config, target: Limits, limits: Limits): Config =>
78
+ limits.work === target.work && limits.deadLetters === target.deadLetters
79
+ ? config
80
+ : {
81
+ ...config,
82
+ retention: { maxAgeMs: SYNC_MAX_AGE_MS, ...config.retention, maxBytes: limits.work },
83
+ deadLetterRetention: { ...config.deadLetterRetention, maxBytes: limits.deadLetters },
84
+ };
85
+
86
+ const createStreamLimits = (connection: NatsConnection, namespace: string) => {
87
+ const manager = once(() => jetstreamManager(connection));
88
+
89
+ /**
90
+ * Work and dead-letter streams per job and queue of this namespace as they
91
+ * are now. Concurrent callers share one listing; a later call lists again,
92
+ * so streams that another process created or changed since are seen.
93
+ */
94
+ let listing: Promise<Map<string, StreamInfo[]>> | undefined;
95
+ const list = () =>
96
+ (listing ??= (async () => {
97
+ const jsm = await manager();
98
+ const streams = new Map<string, StreamInfo[]>();
99
+ for await (const info of jsm.streams.list()) {
100
+ const metadata = info.config.metadata;
101
+ const kind = metadata?.["sync.kind"];
102
+ if (metadata?.["sync.namespace"] !== namespace || metadata["sync.managed"] !== "true") continue;
103
+ // A job's coalescing claims live in a KV bucket without a byte limit.
104
+ if ((kind !== "job" && kind !== "queue") || info.config.name.startsWith("KV_")) continue;
105
+ const key = `${kind}:${metadata["sync.id"]}`;
106
+ streams.set(key, [...(streams.get(key) ?? []), info]);
107
+ }
108
+ return streams;
109
+ })().finally(() => {
110
+ listing = undefined;
111
+ }));
112
+
113
+ /**
114
+ * Limits a job or queue declares: its own, except for an existing stream
115
+ * that holds more than its new limit, which keeps its current limit.
116
+ */
117
+ const keep = async (kind: Kind, id: string, target: Limits): Promise<Limits> => {
118
+ const streams = (await list()).get(`${kind}:${id}`) ?? [];
119
+ const kept = (info: StreamInfo | undefined, maxBytes: number): number => {
120
+ if (!info || info.config.max_bytes === maxBytes || info.state.bytes <= maxBytes) return maxBytes;
121
+ log.warn("Kept the byte limit of a Sync stream that holds more than its new limit", {
122
+ kind,
123
+ id,
124
+ stream: info.config.name,
125
+ limit: info.config.max_bytes,
126
+ target: maxBytes,
127
+ held: info.state.bytes,
128
+ });
129
+ return info.config.max_bytes;
130
+ };
131
+ return {
132
+ work: kept(
133
+ streams.find((info) => info.config.retention === RetentionPolicy.Workqueue),
134
+ target.work,
135
+ ),
136
+ deadLetters: kept(
137
+ streams.find((info) => info.config.retention !== RetentionPolicy.Workqueue),
138
+ target.deadLetters,
139
+ ),
140
+ };
141
+ };
142
+
143
+ /** Streams reported as too full to lower; a topic retries on every use, but logs once. */
144
+ const reported = new Set<string>();
145
+
146
+ /**
147
+ * Provisions a declaration. Each existing stream whose byte limit differs
148
+ * from the declared one gets the declared limit if it holds no more; then
149
+ * the declaration is tried again. Each stream is changed at most once. Of a
150
+ * topic, only the dead-letter stream, whose subjects end in `.dlq.>`, is
151
+ * changed: its event stream keeps Sync's drift check, so applications that
152
+ * declare one topic differently still fail instead of overwriting each
153
+ * other's limit.
154
+ */
155
+ const adopt = async (kind: Kind, id: string, ready: () => Promise<void>): Promise<void> => {
156
+ const changed = new Set<string>();
157
+ for (;;) {
158
+ try {
159
+ return await ready();
160
+ } catch (error) {
161
+ const drift = byteLimitDrift(error);
162
+ if (!drift || changed.has(drift.stream)) throw error;
163
+ changed.add(drift.stream);
164
+ const jsm = await manager();
165
+ const info = await jsm.streams.info(drift.stream);
166
+ if (kind === "topic" && !info.config.subjects.some((subject) => subject.endsWith(".dlq.>"))) throw error;
167
+ if (info.state.bytes > drift.maxBytes) {
168
+ if (reported.has(drift.stream)) throw error;
169
+ reported.add(drift.stream);
170
+ log.warn("Kept the byte limit of a Sync stream that holds more than its new limit", {
171
+ kind,
172
+ id,
173
+ stream: drift.stream,
174
+ limit: info.config.max_bytes,
175
+ target: drift.maxBytes,
176
+ held: info.state.bytes,
177
+ });
178
+ throw error;
179
+ }
180
+ await jsm.streams.update(drift.stream, { max_bytes: drift.maxBytes });
181
+ log.info("Changed the byte limit of a Sync stream", {
182
+ kind,
183
+ id,
184
+ stream: drift.stream,
185
+ from: info.config.max_bytes,
186
+ to: drift.maxBytes,
187
+ });
188
+ }
189
+ }
190
+ };
191
+
192
+ return { keep, adopt };
193
+ };
194
+
195
+ const deferDeadLetters = <T>(resolve: () => Promise<{ deadLetters: DeadLetterStore<T> }>): DeadLetterStore<T> => ({
196
+ page: async (options) => (await resolve()).deadLetters.page(options),
197
+ get: async (input) => (await resolve()).deadLetters.get(input),
198
+ list: async (options) => (await resolve()).deadLetters.list(options),
199
+ requeue: async (input) => (await resolve()).deadLetters.requeue(input),
200
+ delete: async (input) => (await resolve()).deadLetters.delete(input),
201
+ });
202
+
203
+ const deferJob = <Input>(resolve: () => Promise<Job<Input>>): Job<Input> => ({
204
+ ready: async () => (await resolve()).ready(),
205
+ submit: async (job) => (await resolve()).submit(job),
206
+ submitMany: async (jobs, options) => (await resolve()).submitMany(jobs, options),
207
+ submitBatch: async (jobs) => (await resolve()).submitBatch(jobs),
208
+ pause: async (options) => (await resolve()).pause(options),
209
+ resume: async () => (await resolve()).resume(),
210
+ process: async (options, handler) => (await resolve()).process(options, handler),
211
+ deadLetters: deferDeadLetters(resolve),
212
+ });
213
+
214
+ const deferQueue = <T>(resolve: () => Promise<Queue<T>>): Queue<T> => ({
215
+ ready: async () => (await resolve()).ready(),
216
+ send: async (message) => (await resolve()).send(message),
217
+ sendBatch: async (messages) => (await resolve()).sendBatch(messages),
218
+ pause: async (options) => (await resolve()).pause(options),
219
+ resume: async () => (await resolve()).resume(),
220
+ process: async (options, handler) => (await resolve()).process(options, handler),
221
+ reader: async (options) => (await resolve()).reader(options),
222
+ deadLetters: deferDeadLetters(resolve),
223
+ });
224
+
225
+ /** A declared topic whose every provisioning use waits until `ready` has adopted its streams. */
226
+ const deferTopic = <T>(topic: Topic<T>, ready: () => Promise<void>): Topic<T> => {
227
+ const whenReady = <Result>(use: () => Promise<Result>): Promise<Result> => ready().then(use);
228
+ const iterateWhenReady = async function* <Event>(open: () => AsyncIterable<Event>): AsyncIterable<Event> {
229
+ await ready();
230
+ yield* open();
231
+ };
232
+ return {
233
+ ready,
234
+ publish: (input) => whenReady(() => topic.publish(input)),
235
+ publishBatch: (input) => whenReady(() => topic.publishBatch(input)),
236
+ hub: (options) => {
237
+ const hub = topic.hub(options);
238
+ return { subscribe: (subscription) => iterateWhenReady(() => hub.subscribe(subscription)), close: () => hub.close() };
239
+ },
240
+ cursorSequence: (cursor) => topic.cursorSequence(cursor),
241
+ cursorAt: (sequence) => topic.cursorAt(sequence),
242
+ pauseConsumer: (input) => whenReady(() => topic.pauseConsumer(input)),
243
+ resumeConsumer: (input) => whenReady(() => topic.resumeConsumer(input)),
244
+ latestCursor: (options) => whenReady(() => topic.latestCursor(options)),
245
+ head: () => whenReady(() => topic.head()),
246
+ live: (options) => iterateWhenReady(() => topic.live(options)),
247
+ replay: (options) => iterateWhenReady(() => topic.replay(options)),
248
+ follow: (options) => iterateWhenReady(() => topic.follow(options)),
249
+ destroy: () => topic.destroy(),
250
+ process: (options, handler) => whenReady(() => topic.process(options, handler)),
251
+ deadLetters: {
252
+ list: (options) => whenReady(() => topic.deadLetters.list(options)),
253
+ get: (input) => whenReady(() => topic.deadLetters.get(input)),
254
+ delete: (input) => whenReady(() => topic.deadLetters.delete(input)),
255
+ replay: (input) => whenReady(() => topic.deadLetters.replay(input)),
256
+ },
257
+ };
258
+ };
259
+
260
+ /**
261
+ * The process Sync whose jobs, queues, and topics adopt the byte limits of
262
+ * their existing streams before first use. A job or queue is declared to Sync
263
+ * on its first use or on `ready()`; a topic is declared at once. Every other
264
+ * primitive is Sync's own.
265
+ */
266
+ export const withSyncBudgets = (sync: Sync, { connection, namespace }: { connection: NatsConnection; namespace: string }): Sync => {
267
+ const { keep, adopt } = createStreamLimits(connection, namespace);
268
+ /** Every job, queue, and topic declared so far, so `ready()` provisions them like Sync does. */
269
+ const declared = new Map<string, () => Promise<unknown>>();
270
+
271
+ const declareWork = <Config extends QueueConfig | JobConfig, Handle extends { ready(): Promise<void> }>(
272
+ kind: "job" | "queue",
273
+ config: Config,
274
+ create: (config: Config) => Handle,
275
+ ): (() => Promise<Handle>) => {
276
+ // Declared once: Sync refuses a second declaration with other limits, so a
277
+ // failed first use retries provisioning with the limits chosen first.
278
+ const declaration = once(async () => {
279
+ const target = declaredLimits(config);
280
+ return create(withLimits(config, target, await keep(kind, config.id, target)));
281
+ });
282
+ const resolve = once(async () => {
283
+ const handle = await declaration();
284
+ await adopt(kind, config.id, () => handle.ready());
285
+ return handle;
286
+ });
287
+ declared.set(`${kind}:${config.id}`, resolve);
288
+ return resolve;
289
+ };
290
+
291
+ return {
292
+ ...sync,
293
+ ready: async () => {
294
+ await Promise.all([...declared.values()].map((resolve) => resolve()));
295
+ await sync.ready();
296
+ },
297
+ job: <Input>(config: JobConfig) => deferJob(declareWork("job", config, (declaration) => sync.job<Input>(declaration))),
298
+ queue: <T>(config: QueueConfig) => deferQueue(declareWork("queue", config, (declaration) => sync.queue<T>(declaration))),
299
+ topic: <T>(config: TopicConfig) => {
300
+ const topic = sync.topic<T>(config);
301
+ const ready = once(() => adopt("topic", config.id, () => topic.ready()));
302
+ declared.set(`topic:${config.id}`, ready);
303
+ return deferTopic(topic, ready);
304
+ },
305
+ };
306
+ };
@@ -4,7 +4,7 @@ import type { AiSkillTemplate } from "./skills";
4
4
 
5
5
  export const ASSISTANT_CODE_MODE_SKILL = {
6
6
  "key": "assistant:code-mode",
7
- "version": 55,
7
+ "version": 56,
8
8
  "name": "assistant-code-mode",
9
9
  "description": "Inspect and transform unfamiliar data, analyze files, compare results across Cloud apps, or build and improve interactive and agent-only Apps in Assistant Studio. Use for quick code experiments, data analysis, file generation, resource SQL queries and combining discovered Cloud capabilities. For plain arithmetic or date offsets, answer directly or use calculate.",
10
10
  "instructions": "# Assistant code mode\n\nChoose the smallest useful result: one-off answer, exported file, or reusable\nStudio App. Apps may expose agent actions, a display-only dashboard, or both.\nPersistence is optional. One-off scripts stay in their chat and cannot be shared. Reuse an\nexisting Cloud feature when it fits. For a\nquick reading of an uploaded PDF or Office document, `read_file` can return\nMarkdown; use code for exact cells, calculations, original PDF text or positions.\n\n## Start from the contract\n\nLoad the needed `code_*` tools individually through `load_tools` and read their\ninput schemas. They are Assistant tools, not capabilities or functions inside\ncode. Discover other Cloud operations before using `capabilities.run`.\n\nRuntime namespaces are globals: no imports or package installation are needed.\nOnly relative imports of the resource's own source files are supported. There is\nno DOM or native network access. Before using a namespace, read its reference\nbelow for signatures, options and return values. Do not invent methods or infer\nan API from a familiar library. For discovered Cloud capabilities and external\nAPIs, obtain their actual contracts separately.\n\nInspect supplied data before joining, filtering or calculating: column names,\ntypes, units, date ranges and missing values. Ask only for decisions or inputs\nthat cannot be established from available evidence. For several real steps,\nkeep a short `todo_write` plan and update it as work changes; skip ceremony for a\nsmall experiment. A failed experiment should change the next hypothesis.\n\n## First file script\n\nPass exact current-chat manifest paths as `code_run.inputPaths`, and this entry\nas `code_run.code` for a small CSV:\n\n```js\nexport default async () => {\n const [input] = await files.list();\n if (!input) throw new Error(\"Select a CSV input.\");\n const rows = await sheet.fromCsv(await files.read(input.name));\n return { rows: rows.length, columns: Object.keys(rows[0] ?? {}), sample: rows.slice(0, 3) };\n};\n```\n\n`input.name` is the full path, such as `/sales.csv`; pass it unchanged to\n`files.read`, which returns a `File`. CSV rows are objects keyed by headers:\n`rows[0]` is already data. Do not drop it. For older Excel CSVs, use\n`sheet.fromCsv(file, {encoding:\"windows-1252\"})`. Inspect actual headings first.\nFor a tiny experiment without files, `export default () => ({answer:42})` suffices.\nEach run has fresh variables. No saved resource or UI is required.\n\n## Reference routing\n\nRead only the rows relevant to the task. Each link describes its own complete\nsupported surface; links within references add related workflows when needed.\n\n| Task / API | Read |\n| --- | --- |\n| Source entry, input/output files, pickers, CSV, IDs | [Runtime and files](/skills/assistant-code-mode/references/runtime.md) |\n| Inspect PDF pages, read PDF text/positions or XLSX/ODS cells, write ODS | [Documents](/skills/assistant-code-mode/references/documents.md) |\n| Generate a PDF, save one in Files, embed attachments, combine invoice HTML and XML | [PDF generation](/skills/assistant-code-mode/references/pdf.md) |\n| Exact amounts, taxes, allocation, localized money | [Money](/skills/assistant-code-mode/references/money.md) |\n| Export DATEV bookings or SEPA transfers | [DATEV and SEPA](/skills/assistant-code-mode/references/finance.md) |\n| Parse a CAMT bank report | [Bank reports](/skills/assistant-code-mode/references/camt.md) |\n| Calculate, create or read electronic invoices/XML/PDF attachments | [Electronic invoices](/skills/assistant-code-mode/references/einvoice.md) |\n| Controls, layouts and dialogs | [UI and dialogs](/skills/assistant-code-mode/references/ui.md), [Analytics UI](/skills/assistant-code-mode/references/analytics.md) |\n| Chart types, series and axes | [Charts](/skills/assistant-code-mode/references/charts.md) |\n| Long processing, progress, cancellation | [Background work](/skills/assistant-code-mode/references/work.md) |\n| Persist JSON or files locally/shared | [Storage](/skills/assistant-code-mode/references/storage.md) |\n| Copy files between stores; list and download Filesv2 beside Grids documents | [File transfers](/skills/assistant-code-mode/references/files.md) |\n| Resource SQL, schema, row CRUD, imports | [Database](/skills/assistant-code-mode/references/database.md) |\n| Generate text, classify data or extract structured fields | [AI calculations](/skills/assistant-code-mode/references/ai.md) |\n| Discovered Cloud queries/actions | [Capability calls](/skills/assistant-code-mode/references/capabilities.md) |\n| External HTTPS and personal secrets | [HTTP and secrets](/skills/assistant-code-mode/references/http.md) |\n| Call a published App action; declare handlers | [App actions](/skills/assistant-code-mode/references/app-actions.md) |\n| Reuse work across chats, create or edit an App | [Source workflow](/skills/assistant-code-mode/references/source-workflow.md) |\n| Publish, restore, copy | [Publishing](/skills/assistant-code-mode/references/publishing.md) |\n| Find recipients or change App/Skill sharing | [Access](/skills/assistant-code-mode/references/access.md) |\n| Inspect, export, clear server data, or delete an App | [Management](/skills/assistant-code-mode/references/management.md) |\n| Execute, inspect, interact, export, stop, diagnose errors | [Run and debug](/skills/assistant-code-mode/references/debugging.md) |\n| Unfamiliar inputs or cross-app investigation | [Investigation](/skills/assistant-code-mode/references/investigation.md) |\n| Complete app starters | [Examples](/skills/assistant-code-mode/references/examples.md) |\n\nFor a new app, read Source workflow and the closest complete example before\nwriting source, plus only the API references it uses. For analytical reports or\ndashboards, also load `assistant-data-analysis` for metrics and source validation.\n\n## Choose the delivery\n\nFor a one-off chart, calculator, or interactive analysis in this conversation,\nuse `code_run({code,inputPaths})`, test the controls, then\n`code_present({runId,title})`. Read [Chat visualizations](/skills/assistant-code-mode/references/chat.md).\nA successful run is visible to the agent only; present it before saying the\nuser can see it. No saved App or chat file is necessary.\n\nUse a Studio App when the user needs an independently accessible, reusable\napplication. Use `files.save`, `code_export`, and `present` when the requested\nresult is a file. These are separate delivery choices.\n\n## Verify and deliver\n\nRun the actual source (the saved revision for Apps) and test relevant controls with IDs returned by\n`code_run`/`code_interact`, including invalid inputs and picker fixtures. Creating,\ncompiling or saving source does not verify behavior. If `work.status` is\n`running`, wait with `code_inspect({runId,waitMs:30000})`; do not restart the job.\nInspect only when the returned snapshot needs more detail. Errors and\n`outputTruncated` are not successful complete results.\n\nFor a CSV, call `await files.save(sheet.toCsv(rows), \"result.csv\")` inside code;\nfor a spreadsheet, `await files.save(await sheet.toOds(sheets), \"result.ods\")`.\nThen call the **tool** `code_export` with the returned `runId` and captured file\nname, and `present` its returned chat path. `files.save` returns no path.\nReuse exported data via its path/version rather than retyping truncated output.\nReconcile row counts, exclusions and totals before reporting findings.\n\nOpen GUI apps with `code_open`. Saving or testing does\nnot replace a user's already-running app. Stop runs no longer needed that retain\nUI, jobs or output files. Never claim an unexecuted result is verified.\n\nAgent execution runs independently of the user's tab. Agent local storage is\ntemporary; shared storage, database writes and external actions are real, even\nin tests. Cancellation and source restore do not undo them. Apps select local\nfiles explicitly; they never gain implicit access to chat attachments. Use\n`code_secret` for credentials, never chat or app controls. Honor normal access\nand approval decisions; availability is not authorization for unrelated actions.",
@@ -56,7 +56,7 @@ export const ASSISTANT_CODE_MODE_SKILL = {
56
56
  },
57
57
  {
58
58
  "path": "references/einvoice.md",
59
- "content": "# Electronic invoices\n\n`einvoice` is a global. These methods return Results: inspect `ok`, then use\n`data` or `error: {code,status,message,issues}`. Issue entries contain\n`{code,path,message,line?,column?}`; paths have zero-based row indices.\n\n| Call | Successful `data` |\n| --- | --- |\n| `einvoice.validate(input)` | `Invoice` |\n| `einvoice.calculate(lines)` | `InvoiceCalculation` |\n| `einvoice.serialize(invoice, {format: \"zugferd-2.5-en16931\"})` | `{format, xml: string, bytes: Uint8Array}` |\n| `einvoice.parseXml(xml, options?)` | `ParsedInvoice` |\n| `await einvoice.parsePdf(bytes, options?)` | `ParsedInvoice` |\n| `einvoice.parseXml(xml, {mode: \"incoming\"})` | `ParsedIncomingInvoice` |\n| `await einvoice.parsePdf(bytes, {mode: \"incoming\"})` | `ParsedIncomingInvoice` |\n\nAll calls except `parsePdf` are synchronous. `parsePdf` takes a `Uint8Array`,\nfor example `new Uint8Array(await file.arrayBuffer())`. It reads embedded XML,\nnot scanned pages or arbitrary visual invoice layouts. For those, use the\n[local PDF text reader](documents.md) or the agent's document/vision tools.\n\nThe supported slice covers EUR CII EN16931 invoices, credit notes and self-billing,\nVAT categories S/Z/E/AE/K/G/O, units C62/HUR/DAY/KGM. Generation does not\nsupport UBL, XRechnung, discounts or prepayments. Readers preserve declared totals;\nparsing is not arithmetic verification. Validation is not XSD or Schematron\ncertification. No XSD validator is exposed.\n\n## Complete input and result shapes\n\nType descriptions only; no imports are needed. All fields are required unless\nmarked `?`; unknown fields are rejected.\n\n```ts\ntype Party = {\n name: string; id?: string; vatId: string; // \"\" when the party has no VAT ID\n address: {line1: string; city: string; postalCode: string; countryCode: string};\n};\ntype Tax = {\n taxCategory?: \"S\" | \"Z\" | \"E\" | \"AE\" | \"K\" | \"G\" | \"O\"; // default \"S\"\n taxRate: string; taxExemptionReason?: string; taxExemptionReasonCode?: string;\n};\ntype InvoiceLine = Tax & {\n id: string; name: string; description?: string;\n quantity: string; unitPrice: string; unitCode: \"C62\" | \"HUR\" | \"DAY\" | \"KGM\";\n netAmount?: string;\n};\ntype InvoiceTotals = {\n netAmount: string; taxAmount: string; grossAmount: string; dueAmount: string;\n taxGroups: (Tax & {netAmount: string; taxAmount: string})[];\n};\ntype Invoice = {\n kind: \"invoice\" | \"creditNote\" | \"selfBilling\";\n number: string; invoiceDate: string; serviceDate: string; dueDate: string;\n currency: \"EUR\"; seller: Party & {taxRegistrationId?: string}; buyer: Party;\n deliverToCountryCode?: string; buyerReference: string;\n notes?: string[];\n precedingInvoice?: {number: string; invoiceDate: string};\n payment: {iban: string; accountName: string};\n lines: InvoiceLine[]; totals?: InvoiceTotals;\n};\ntype InvoiceCalculation = InvoiceTotals & {\n lines: (InvoiceLine & {netAmount: string})[];\n};\ntype ParsedInvoice = {\n format: \"zugferd-2.5-en16931\"; profile: string; xml: string;\n invoice: Invoice; filename?: string;\n};\ntype ParseOptions = {maxCharacters?: number; maxElements?: number; maxDepth?: number};\ntype PdfOptions = ParseOptions & {maxPdfBytes?: number};\n```\n\nXML options default to 10 Mi UTF-16 code units, 100,000 elements, depth 64.\nPDF input defaults to 25 MiB. Overrides must be positive safe integers.\nA parser result's business fields are under **`data.invoice`**. Calculated\namounts are directly under **`data.netAmount`**, etc., with no `data.totals` wrapper.\n\n- Dates are real `YYYY-MM-DD` dates; `dueDate` cannot precede `invoiceDate`.\n Credit notes require `precedingInvoice`, whose date cannot be later than the\n credit note; other kinds cannot supply it. Credit-note amounts stay unsigned.\n- Lines: 1–1000, unique IDs. Quantities are positive, prices nonnegative,\n VAT rates at most 100. Decimal strings allow up to four\n fractional digits and no leading zeros. Totals/net amounts require exactly\n two fractional digits; do not convert through JavaScript Number.\n- Country codes: two uppercase letters. `payment.iban` must be valid.\n Required text is nonblank valid XML text. Limits: number/reference/line ID/VAT ID\n 100; names/address line/accountName 200; city 100; postalCode 20;\n line description and each note 4000; at most 100 notes.\n- Category S needs a positive rate; every other category uses `taxRate: \"0\"`\n and zero tax. E/AE/K/G/O need `taxExemptionReason` or a VATEX\n `taxExemptionReasonCode`; S/Z forbid both. O cannot be mixed with other\n categories and requires `vatId: \"\"` for both parties. A seller without a VAT\n ID needs `seller.id` and, outside O, `seller.taxRegistrationId`. AE/K need a\n buyer VAT ID, K/G a seller VAT ID, and K `deliverToCountryCode`.\n- `calculate` rounds each line half up to cents, then VAT per category and rate. It recalculates\n line `netAmount`; `serialize` also rejects supplied line/totals values that\n disagree. Render these calculated amounts in HTML instead of another arithmetic path.\n\n## Minimal supported invoice\n\nUse real business data and an app-owned invoice number. This illustrative\nfixture demonstrates the required fields; it is not a document to issue.\n\n```js\nconst invoice = {\n kind: \"invoice\",\n number: \"EXAMPLE-42\",\n invoiceDate: \"2026-09-15\",\n serviceDate: \"2026-09-15\",\n dueDate: \"2026-09-30\",\n currency: \"EUR\",\n seller: {\n name: \"Example Seller\", vatId: \"DE123456789\",\n address: { line1: \"Street 1\", city: \"Ulm\", postalCode: \"89073\", countryCode: \"DE\" },\n },\n buyer: {\n name: \"Example Buyer\", vatId: \"DE987654321\",\n address: { line1: \"Street 2\", city: \"Berlin\", postalCode: \"10115\", countryCode: \"DE\" },\n },\n buyerReference: \"ORDER-42\",\n payment: { iban: \"DE89370400440532013000\", accountName: \"Example Seller\" },\n lines: [{ id: \"1\", name: \"Service\", quantity: \"2.0000\", unitPrice: \"50.0000\", unitCode: \"HUR\", taxRate: \"19.00\" }],\n};\nconst result = einvoice.serialize(invoice, { format: \"zugferd-2.5-en16931\" });\nif (!result.ok) throw new Error(JSON.stringify(result.error));\nawait files.save(new Blob([result.data.bytes], { type: \"application/xml\" }), \"invoice.xml\");\n```\n\nNever infer a missing VAT identifier, tax category, exemption reason, account\nor business reference merely to satisfy input validation.\n\n## Reading received invoices\n\n`{mode: \"incoming\"}` (plus the same limits) reads a broader separate model:\nalso XRechnung 3.0/2.3 CII, other currencies, discounts, prepayments, all\npayment means and optional references. `data` is\n`{format: \"cii-en16931\", profile, xml, invoice, unmapped, filename?}`.\nAmounts are declared strings, never recalculated; O lines have no `taxRate`.\n`unmapped` lists supplementary XML elements and attributes with their paths; review it before\naccounting. Do not pass this `invoice` to `validate` or `serialize`.\n\nFor an invoice PDF, pass `serialized.data.xml` to\n[`pdf.facturX`](pdf.md) with profile `\"EN 16931\"` and matching HTML.\nNumbering, business mapping, issuance and persistence belong to the app.\n"
59
+ "content": "# Electronic invoices\n\n`einvoice` is a global. These methods return Results: inspect `ok`, then use\n`data` or `error: {code,status,message,issues}`. Issue entries contain\n`{code,path,message,line?,column?}`; paths have zero-based row indices.\n\n| Call | Successful `data` |\n| --- | --- |\n| `einvoice.validate(input)` | `Invoice` |\n| `einvoice.calculate(lines)` | `InvoiceCalculation` |\n| `einvoice.serialize(invoice, {format: \"zugferd-2.5-en16931\"})` | `{format, xml: string, bytes: Uint8Array}` |\n| `einvoice.parseXml(xml, options?)` | `ParsedInvoice` |\n| `await einvoice.parsePdf(bytes, options?)` | `ParsedInvoice` |\n| `einvoice.parseXml(xml, {mode: \"incoming\"})` | `ParsedIncomingInvoice` |\n| `await einvoice.parsePdf(bytes, {mode: \"incoming\"})` | `ParsedIncomingInvoice` |\n\nAll calls except `parsePdf` are synchronous. `parsePdf` takes a `Uint8Array`,\nfor example `new Uint8Array(await file.arrayBuffer())`. It reads embedded XML,\nnot scanned pages or arbitrary visual invoice layouts. For those, use the\n[local PDF text reader](documents.md) or the agent's document/vision tools.\n\nThe supported slice covers EUR CII EN16931 invoices, credit notes, self-billing\nand self-billed credit notes, VAT categories S/Z/E/AE/K/G/O, units\nC62/HUR/DAY/KGM, and payment by credit transfer, cash, online service or\nclearing, or no payment means. Generation does not support UBL, XRechnung,\ndiscounts or prepayments. Readers preserve declared totals; parsing is not\narithmetic verification. Validation is not XSD or Schematron certification.\nNo XSD validator is exposed.\n\n## Complete input and result shapes\n\nType descriptions only; no imports are needed. All fields are required unless\nmarked `?`; unknown fields are rejected.\n\n```ts\ntype Party = {\n name: string; id?: string; vatId: string; // \"\" when the party has no VAT ID\n address: {line1: string; city: string; postalCode: string; countryCode: string};\n};\ntype Tax = {\n taxCategory?: \"S\" | \"Z\" | \"E\" | \"AE\" | \"K\" | \"G\" | \"O\"; // default \"S\"\n taxRate: string; taxExemptionReason?: string; taxExemptionReasonCode?: string;\n};\ntype InvoiceLine = Tax & {\n id: string; name: string; description?: string;\n quantity: string; unitPrice: string; unitCode: \"C62\" | \"HUR\" | \"DAY\" | \"KGM\";\n netAmount?: string;\n};\ntype InvoiceTotals = {\n netAmount: string; taxAmount: string; grossAmount: string; dueAmount: string;\n taxGroups: (Tax & {netAmount: string; taxAmount: string})[];\n};\ntype Invoice = {\n kind: \"invoice\" | \"creditNote\" | \"selfBilling\" | \"selfBillingCreditNote\";\n number: string; invoiceDate: string; dueDate: string;\n serviceDate?: string; period?: {startDate?: string; endDate?: string};\n currency: \"EUR\"; seller: Party & {taxRegistrationId?: string}; buyer: Party;\n deliverToCountryCode?: string; buyerReference: string;\n notes?: string[];\n precedingInvoice?: {number: string; invoiceDate: string};\n payment?: {\n typeCode?: \"10\" | \"30\" | \"58\" | \"68\" | \"97\"; // default \"58\"\n information?: string; iban?: string; accountName?: string;\n };\n lines: InvoiceLine[]; totals?: InvoiceTotals;\n};\ntype InvoiceCalculation = InvoiceTotals & {\n lines: (InvoiceLine & {netAmount: string})[];\n};\ntype ParsedInvoice = {\n format: \"zugferd-2.5-en16931\"; profile: string; xml: string;\n invoice: Invoice; filename?: string;\n};\ntype ParseOptions = {maxCharacters?: number; maxElements?: number; maxDepth?: number};\ntype PdfOptions = ParseOptions & {maxPdfBytes?: number};\n```\n\nXML options default to 10 Mi UTF-16 code units, 100,000 elements, depth 64.\nPDF input defaults to 25 MiB. Overrides must be positive safe integers.\nA parser result's business fields are under **`data.invoice`**. Calculated\namounts are directly under **`data.netAmount`**, etc., with no `data.totals` wrapper.\nA parsed invoice carries `serviceDate`, `period`, `payment` and the account\nfields only when the XML does: check each before reading it, for example\n`invoice.payment?.iban`. A parsed `payment` always has its `typeCode`.\n\n- Dates are real `YYYY-MM-DD` dates; `dueDate` cannot precede `invoiceDate`.\n `serviceDate` (delivery date) and `period` (invoicing period) are optional\n and can be combined. A `period` needs a start or an end, and its end cannot\n precede its start.\n- `creditNote` and `selfBillingCreditNote` require `precedingInvoice`; every\n kind may supply it, and its date cannot be later than `invoiceDate`.\n Credit-note amounts stay unsigned.\n- `payment.typeCode`: `\"58\"` SEPA credit transfer, `\"30\"` credit transfer,\n `\"10\"` cash, `\"68\"` online payment service, `\"97\"` clearing between\n partners. 30 and 58 require `iban`; `accountName` is optional. The other\n codes forbid both. `information` is free text for any code. Omit `payment`\n when no payment means applies. Any other code fails both writing and the\n default reader; read such invoices with `{mode: \"incoming\"}`.\n- Lines: 1–1000, unique IDs. Quantities are positive, prices nonnegative,\n VAT rates at most 100. Decimal strings allow up to four\n fractional digits and no leading zeros. Totals/net amounts require exactly\n two fractional digits; do not convert through JavaScript Number.\n- Country codes: two uppercase letters. A supplied `payment.iban` must be valid.\n Required text is nonblank valid XML text. Limits: number/reference/line ID/VAT ID\n 100; names/address line/accountName 200; city 100; postalCode 20;\n line description, `payment.information` and each note 4000; at most 100 notes.\n- Category S needs a positive rate; every other category uses `taxRate: \"0\"`\n and zero tax. E/AE/K/G/O need `taxExemptionReason` or a VATEX\n `taxExemptionReasonCode`; S/Z forbid both. O cannot be mixed with other\n categories and requires `vatId: \"\"` for both parties. A seller without a VAT\n ID needs `seller.id` and, outside O, `seller.taxRegistrationId`. AE/K need a\n buyer VAT ID, K/G a seller VAT ID, and K `deliverToCountryCode` plus a\n `serviceDate` or `period`.\n- `calculate` rounds each line half up to cents, then VAT per category and rate. It recalculates\n line `netAmount`; `serialize` also rejects supplied line/totals values that\n disagree. Render these calculated amounts in HTML instead of another arithmetic path.\n\n## Example invoice\n\nUse real business data and an app-owned invoice number. This illustrative\nfixture is a credit-transfer invoice with a delivery date; it is not a\ndocument to issue.\n\n```js\nconst invoice = {\n kind: \"invoice\",\n number: \"EXAMPLE-42\",\n invoiceDate: \"2026-09-15\",\n serviceDate: \"2026-09-15\",\n dueDate: \"2026-09-30\",\n currency: \"EUR\",\n seller: {\n name: \"Example Seller\", vatId: \"DE123456789\",\n address: { line1: \"Street 1\", city: \"Ulm\", postalCode: \"89073\", countryCode: \"DE\" },\n },\n buyer: {\n name: \"Example Buyer\", vatId: \"DE987654321\",\n address: { line1: \"Street 2\", city: \"Berlin\", postalCode: \"10115\", countryCode: \"DE\" },\n },\n buyerReference: \"ORDER-42\",\n payment: { iban: \"DE89370400440532013000\", accountName: \"Example Seller\" },\n lines: [{ id: \"1\", name: \"Service\", quantity: \"2.0000\", unitPrice: \"50.0000\", unitCode: \"HUR\", taxRate: \"19.00\" }],\n};\nconst result = einvoice.serialize(invoice, { format: \"zugferd-2.5-en16931\" });\nif (!result.ok) throw new Error(JSON.stringify(result.error));\nawait files.save(new Blob([result.data.bytes], { type: \"application/xml\" }), \"invoice.xml\");\n```\n\nNever infer a missing VAT identifier, tax category, exemption reason, delivery\ndate, account or business reference merely to satisfy input validation.\n\n## Reading received invoices\n\n`{mode: \"incoming\"}` (plus the same limits) reads a broader separate model:\nalso XRechnung 3.0/2.3 CII, other currencies, discounts, prepayments, all\npayment means and optional references. `data` is\n`{format: \"cii-en16931\", profile, xml, invoice, unmapped, filename?}`.\nAmounts are declared strings, never recalculated; O lines have no `taxRate`.\n`unmapped` lists supplementary XML elements and attributes with their paths; review it before\naccounting. Do not pass this `invoice` to `validate` or `serialize`.\n\nFor an invoice PDF, pass `serialized.data.xml` to\n[`pdf.facturX`](pdf.md) with profile `\"EN 16931\"` and matching HTML.\nNumbering, business mapping, issuance and persistence belong to the app.\n"
60
60
  },
61
61
  {
62
62
  "path": "references/examples.md",
package/src/config/env.ts CHANGED
@@ -72,6 +72,11 @@ const registry = defineEnv({
72
72
  example: "localhost:3000",
73
73
  doc: "Public Cloud URL used to bootstrap the `app.url` setting; the stored setting wins once written.",
74
74
  },
75
+ GOTENBERG_URL: {
76
+ schema: z.string(),
77
+ example: "http://localhost:3001",
78
+ doc: "Internal Gotenberg base URL used to bootstrap the `gotenberg.url` PDF rendering setting; the stored setting wins once written.",
79
+ },
75
80
  APP_SECRET: {
76
81
  schema: z.string(),
77
82
  default: "",
@@ -9,20 +9,33 @@ type LocaleContext = {
9
9
  req: { raw: { headers: Headers } };
10
10
  };
11
11
 
12
+ /**
13
+ * Languages Cloud ships platform catalogs for and offers in its language
14
+ * picker. `Accept-Language` negotiation prefers a tag in one of these.
15
+ */
16
+ const CATALOG_LANGUAGES = new Set(["en", "de"]);
17
+
12
18
  /**
13
19
  * The caller's explicit locale preference, or `undefined` when the request
14
20
  * carries none. Precedence: `x-cloud-locale` transport metadata, then the
15
- * `cloud.locale` cookie, then `Accept-Language` in quality order. Every
16
- * candidate is canonicalized; invalid tags fall through to the next source.
21
+ * `cloud.locale` cookie, then `Accept-Language`. Every candidate is
22
+ * canonicalized; invalid tags fall through to the next source.
23
+ *
24
+ * `Accept-Language` picks the first tag in quality order whose language has a
25
+ * catalog, keeping its region (`de-CH`, `en-GB`) for formatting. Without such a
26
+ * tag, the first valid tag still wins so formatting follows the browser.
17
27
  */
18
28
  export const preferredLocale = (headers: Headers): string | undefined => {
19
29
  const explicit = canonicalLocale(headers.get(LOCALE_HEADER)) ?? canonicalLocale(readCookie(headers, LOCALE_COOKIE));
20
30
  if (explicit) return explicit;
31
+ let firstValid: string | undefined;
21
32
  for (const tag of i18n.parseAcceptLanguage(headers.get("Accept-Language"))) {
22
33
  const candidate = canonicalLocale(tag);
23
- if (candidate) return candidate;
34
+ if (!candidate) continue;
35
+ if (CATALOG_LANGUAGES.has(new Intl.Locale(candidate).language)) return candidate;
36
+ firstValid ??= candidate;
24
37
  }
25
- return undefined;
38
+ return firstValid;
26
39
  };
27
40
 
28
41
  /**
@@ -1,5 +1,5 @@
1
1
  import type { Context, ValidationTargets } from "hono";
2
- import { validator as honoValidator } from "hono-openapi";
2
+ import { type GenerateSpecOptions, validator as honoValidator } from "hono-openapi";
3
3
  import type { ZodType } from "zod";
4
4
 
5
5
  export type ValidatorError = Readonly<{
@@ -9,6 +9,28 @@ export type ValidatorError = Readonly<{
9
9
 
10
10
  export type ValidatorErrorResolver = (context: Context) => ValidatorError;
11
11
 
12
+ /**
13
+ * OpenAPI description of the 400 body `validator()` sends: `{ message }`, or
14
+ * `{ code, message }` from a route's error resolver. The generated spec of
15
+ * every application uses it for validated routes that do not describe their
16
+ * own 400, in place of hono-openapi's default body, which Cloud never sends.
17
+ */
18
+ export const validationErrorResponse: Exclude<GenerateSpecOptions["defaultValidationErrorResponse"], boolean> = {
19
+ description: "Validation failed",
20
+ content: {
21
+ "application/json": {
22
+ schema: {
23
+ type: "object",
24
+ properties: {
25
+ message: { type: "string" },
26
+ code: { type: "string" },
27
+ },
28
+ required: ["message"],
29
+ },
30
+ },
31
+ },
32
+ };
33
+
12
34
  /**
13
35
  * Zod validator middleware with pretty error messages and OpenAPI support.
14
36
  * Uses hono-openapi validator for automatic OpenAPI schema generation.
@@ -132,6 +132,11 @@ export const latestGatewayRouteSnapshot = async (): Promise<GatewayRouteSnapshot
132
132
  return all.sort((a, b) => b.updatedAt - a.updatedAt)[0] ?? null;
133
133
  };
134
134
 
135
+ /**
136
+ * Dead letters keep the event stream's limit. Gateway Ops leaves dead letters
137
+ * when rollup writes fail; if they exceeded a lowered limit, this topic, and
138
+ * with it Gateway Ops' start, would fail until they expired.
139
+ */
135
140
  export const gatewayTelemetryTopic = lazySync((sync) =>
136
141
  sync.topic<GatewayTelemetryEvent>({
137
142
  id: TOPIC_ID,
@@ -148,6 +148,7 @@ export {
148
148
  MARKDOWN_PDF_TEMPLATE_IDS,
149
149
  MarkdownPdfError,
150
150
  buildMarkdownPdfHtml,
151
+ buildPresetPdfHtml,
151
152
  getGotenbergConfig,
152
153
  attachPdfFiles,
153
154
  attachPdfFilesWithConfig,
@@ -163,6 +164,7 @@ export {
163
164
  testGotenberg,
164
165
  } from "./pdf";
165
166
  export type {
167
+ BuildPresetPdfHtmlInput,
166
168
  GotenbergConfig,
167
169
  AttachPdfFilesInput,
168
170
  RenderFacturXHtmlToPdfInput,
@@ -22,6 +22,7 @@ export {
22
22
  testGotenberg,
23
23
  } from "./gotenberg";
24
24
  export type {
25
+ BuildPresetPdfHtmlInput,
25
26
  MarkdownPdfErrorCode,
26
27
  MarkdownPdfTemplateId,
27
28
  RenderMarkdownToPdfInput,
@@ -29,6 +30,7 @@ export type {
29
30
  } from "./markdown";
30
31
  export {
31
32
  buildMarkdownPdfHtml,
33
+ buildPresetPdfHtml,
32
34
  MARKDOWN_PDF_MAX_CUSTOM_CSS_BYTES,
33
35
  MARKDOWN_PDF_MAX_MARKDOWN_BYTES,
34
36
  MARKDOWN_PDF_TEMPLATE_IDS,
@@ -38,6 +38,27 @@ export type RenderMarkdownToPdfInput = {
38
38
 
39
39
  export type RenderMarkdownToPdfOptions = RenderHtmlToPdfOptions;
40
40
 
41
+ export type BuildPresetPdfHtmlInput = {
42
+ /** Body HTML the application rendered from its own document format. */
43
+ html: string;
44
+ templateId?: MarkdownPdfTemplateId;
45
+ customCss?: string;
46
+ /** Application CSS for its own elements, applied after the preset and before `customCss`. */
47
+ css?: string;
48
+ };
49
+
50
+ // Noto Serif, which Gotenberg ships, forms f-ligatures for the report body. It
51
+ // has no "≠" but has "=" and the combining overlay U+0338, so Chromium would
52
+ // build "≠" from both and draw "=/". Without U+0338 in the range, such
53
+ // characters fall back whole to the next font. local() takes the PostScript
54
+ // name and fetches nothing.
55
+ const REPORT_SERIF_RANGE = "U+0-337, U+339-10FFFF";
56
+ const REPORT_SERIF_FACES = `
57
+ @font-face { font-family: "Report Noto Serif"; src: local("NotoSerif-Regular"); unicode-range: ${REPORT_SERIF_RANGE}; }
58
+ @font-face { font-family: "Report Noto Serif"; src: local("NotoSerif-Bold"); font-weight: bold; unicode-range: ${REPORT_SERIF_RANGE}; }
59
+ @font-face { font-family: "Report Noto Serif"; src: local("NotoSerif-Italic"); font-style: italic; unicode-range: ${REPORT_SERIF_RANGE}; }
60
+ @font-face { font-family: "Report Noto Serif"; src: local("NotoSerif-BoldItalic"); font-weight: bold; font-style: italic; unicode-range: ${REPORT_SERIF_RANGE}; }`;
61
+
41
62
  const TEMPLATE_CSS: Record<MarkdownPdfTemplateId, string> = {
42
63
  document: `
43
64
  @page { size: A4; margin: 22mm 20mm 24mm; }
@@ -58,9 +79,9 @@ th { background: #f1f5f9; font-weight: 650; }
58
79
  thead { display: table-header-group; } tr { break-inside: avoid; }
59
80
  hr { border: 0; border-top: 1px solid #cbd5e1; margin: 1.5em 0; }
60
81
  `,
61
- report: `
82
+ report: `${REPORT_SERIF_FACES}
62
83
  @page { size: A4; margin: 24mm 22mm 26mm; }
63
- :root { color: #263244; font: 10.75pt/1.58 Georgia, "Times New Roman", serif; }
84
+ :root { color: #263244; font: 10.75pt/1.58 Georgia, "Report Noto Serif", "Times New Roman", serif; }
64
85
  body { margin: 0; background: #fff; }
65
86
  .markdown-document { max-width: 100%; overflow-wrap: anywhere; }
66
87
  h1, h2, h3, h4, h5, h6 { color: #13233a; font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; line-height: 1.18; break-after: avoid-page; }
@@ -114,6 +135,12 @@ const safeLink = (value: string): string | null => {
114
135
  const renderMarkdown = (source: string): string => {
115
136
  const renderer = new Renderer();
116
137
  renderer.html = ({ text }: Tokens.HTML | Tokens.Tag) => escapeHtml(text);
138
+ // marked flags text that follows an inline <pre>, <code>, <kbd> or <script>
139
+ // tag as already escaped, because it expects that tag to reach the output as
140
+ // HTML. The tag is escaped here, so the text after it is escaped as well.
141
+ renderer.text = function (token: Tokens.Text | Tokens.Escape) {
142
+ return Renderer.prototype.text.call(this, token.type === "text" && token.escaped ? { ...token, escaped: false } : token);
143
+ };
117
144
  renderer.image = ({ href, title, text }: Tokens.Image) => {
118
145
  const label = escapeHtml(text.trim() ? `Image: ${text}` : "Image");
119
146
  const safeHref = safeLink(href);
@@ -144,6 +171,10 @@ const renderMarkdown = (source: string): string => {
144
171
  }
145
172
  };
146
173
 
174
+ // Keep CSS inside its style element even when it contains an HTML end tag.
175
+ // The backslash is valid CSS escaping but no longer an HTML token.
176
+ const styleText = (css: string): string => css.replace(/<\/style/giu, "<\\/style");
177
+
147
178
  const validateCustomCss = (customCss: string): string => {
148
179
  if (byteLength(customCss) > MARKDOWN_PDF_MAX_CUSTOM_CSS_BYTES) {
149
180
  throw new MarkdownPdfError("invalid_css", "Custom CSS exceeds the 32 KiB limit.", "css_too_large");
@@ -168,15 +199,15 @@ const validateCustomCss = (customCss: string): string => {
168
199
  }
169
200
  });
170
201
 
171
- // Keep the CSS inside its style element even when input contains an HTML
172
- // end tag. The backslash is valid CSS escaping but no longer an HTML token.
173
- return root.toString().replace(/<\/style/giu, "<\\/style");
202
+ return styleText(root.toString());
174
203
  };
175
204
 
176
- export const buildMarkdownPdfHtml = (input: RenderMarkdownToPdfInput): string => {
177
- if (typeof input.markdown !== "string" || !input.markdown.trim()) {
178
- throw new MarkdownPdfError("bad_input", "Markdown must not be empty.", "markdown_empty");
179
- }
205
+ /**
206
+ * Wrap application-rendered HTML in the same print presets and validated
207
+ * custom CSS as Markdown PDFs, for documents whose Markdown dialect the
208
+ * application renders itself.
209
+ */
210
+ export const buildPresetPdfHtml = (input: BuildPresetPdfHtmlInput): string => {
180
211
  const templateId = input.templateId;
181
212
  if (templateId !== undefined && !MARKDOWN_PDF_TEMPLATE_IDS.includes(templateId)) {
182
213
  throw new MarkdownPdfError("bad_input", "Unknown Markdown PDF template.", "unknown_template");
@@ -185,8 +216,8 @@ export const buildMarkdownPdfHtml = (input: RenderMarkdownToPdfInput): string =>
185
216
  const suppliedCustomCss = input.customCss?.trim() ?? "";
186
217
  const customCss = suppliedCustomCss ? validateCustomCss(input.customCss ?? "") : "";
187
218
  const presetCss = templateId ? TEMPLATE_CSS[templateId] : customCss ? "" : TEMPLATE_CSS.document;
188
- const stylesheet = `${presetCss}${presetCss && customCss ? `\n/* Custom CSS overrides */\n` : ""}${customCss}`;
189
- const content = renderMarkdown(input.markdown);
219
+ const baseCss = [presetCss, input.css ? styleText(input.css) : ""].filter(Boolean).join("\n");
220
+ const stylesheet = `${baseCss}${baseCss && customCss ? `\n/* Custom CSS overrides */\n` : ""}${customCss}`;
190
221
  return `<!doctype html>
191
222
  <html lang="en">
192
223
  <head>
@@ -195,10 +226,17 @@ export const buildMarkdownPdfHtml = (input: RenderMarkdownToPdfInput): string =>
195
226
  <meta http-equiv="Content-Security-Policy" content="default-src 'none'; base-uri 'none'; connect-src 'none'; font-src 'none'; form-action 'none'; frame-src 'none'; img-src 'none'; media-src 'none'; object-src 'none'; script-src 'none'; style-src 'unsafe-inline'">
196
227
  <style>${stylesheet}</style>
197
228
  </head>
198
- <body><main class="markdown-document">${content}</main></body>
229
+ <body><main class="markdown-document">${input.html}</main></body>
199
230
  </html>`;
200
231
  };
201
232
 
233
+ export const buildMarkdownPdfHtml = (input: RenderMarkdownToPdfInput): string => {
234
+ if (typeof input.markdown !== "string" || !input.markdown.trim()) {
235
+ throw new MarkdownPdfError("bad_input", "Markdown must not be empty.", "markdown_empty");
236
+ }
237
+ return buildPresetPdfHtml({ html: renderMarkdown(input.markdown), templateId: input.templateId, customCss: input.customCss });
238
+ };
239
+
202
240
  export const renderMarkdownToPdfWithConfig = (
203
241
  input: RenderMarkdownToPdfInput,
204
242
  config: GotenbergConfig,
@@ -317,6 +317,8 @@ export const CORE_SETTINGS = {
317
317
  default: "",
318
318
  description: "Internal base URL of the Gotenberg service used for HTML-to-PDF rendering.",
319
319
  placeholder: "e.g. http://gotenberg:3000",
320
+ envFallback: () => env.GOTENBERG_URL,
321
+ envBootstrap: () => env.GOTENBERG_URL,
320
322
  },
321
323
  "gotenberg.username": {
322
324
  kind: "string",
@@ -3,10 +3,8 @@ import type { CloudTheme } from "../shared/theme";
3
3
  import { createPreferenceController } from "./preference-controller";
4
4
 
5
5
  type LayoutPreferencesProps = {
6
- class?: string;
7
6
  initialTheme: CloudTheme;
8
7
  position: DropdownPosition;
9
- triggerClass?: string;
10
8
  };
11
9
 
12
10
  export default function LayoutPreferences(props: LayoutPreferencesProps) {
@@ -14,21 +12,8 @@ export default function LayoutPreferences(props: LayoutPreferencesProps) {
14
12
  const preferences = createPreferenceController(props.initialTheme, locale);
15
13
 
16
14
  return (
17
- <Dropdown.Root
18
- class={props.class}
19
- items={preferences.items()}
20
- label={preferences.messages().preferencesMenuLabel}
21
- position={props.position}
22
- width="14rem"
23
- >
24
- <Dropdown.Trigger
25
- class={props.triggerClass}
26
- iconOnly
27
- label={preferences.messages().preferencesMenuLabel}
28
- size="sm"
29
- tooltip={false}
30
- variant="secondary"
31
- >
15
+ <Dropdown.Root items={preferences.items()} label={preferences.messages().preferencesMenuLabel} position={props.position} width="14rem">
16
+ <Dropdown.Trigger iconOnly label={preferences.messages().preferencesMenuLabel} size="sm" tooltip={false} variant="secondary">
32
17
  <i class="ti ti-adjustments-horizontal" aria-hidden="true" />
33
18
  </Dropdown.Trigger>
34
19
  </Dropdown.Root>
@@ -1,46 +1,70 @@
1
1
  import { LocaleProvider } from "@k2b/ui";
2
2
  import type { JSX } from "solid-js/jsx-runtime";
3
3
  import { getLocale } from "../server/locale";
4
+ import { resolveAppPresentations } from "../shared/app-presentation";
4
5
  import { readThemeFromCookieHeader } from "../shared/theme";
5
- import LayoutPreferences from "./LayoutPreferences.island";
6
6
  import type { MinimalLayoutContext } from "./layout-context";
7
+ import MinimalLayoutPreferences from "./MinimalLayoutPreferences.island";
8
+ import { profilePreferencesMessages } from "./profile-preferences-messages";
7
9
  import TimezoneCookie from "./TimezoneCookie.island";
8
10
 
11
+ /**
12
+ * @deprecated The language and theme settings always sit in the footer; a
13
+ * position is treated like `true`.
14
+ */
9
15
  export type MinimalLayoutPreferencePosition = "top-left" | "top-right" | "bottom-left" | "bottom-right";
10
16
 
11
17
  export type MinimalLayoutProps = {
12
18
  children: JSX.Element;
13
19
  c: MinimalLayoutContext;
14
- /** Position the shared language/theme control, or disable it entirely. */
15
- preferences?: MinimalLayoutPreferencePosition | false;
16
- };
17
-
18
- const menuPosition = (position: MinimalLayoutPreferencePosition) => {
19
- if (position === "top-left") return "bottom-right" as const;
20
- if (position === "top-right") return "bottom-left" as const;
21
- if (position === "bottom-left") return "top-right" as const;
22
- return "top-left" as const;
20
+ /**
21
+ * Render the footer with legal links and the language and theme settings
22
+ * (default), or `false` for embeds and fixed presentation surfaces.
23
+ */
24
+ preferences?: boolean | MinimalLayoutPreferencePosition;
23
25
  };
24
26
 
25
27
  export default function MinimalLayout(props: MinimalLayoutProps) {
26
28
  const cookie = props.c.req.raw.headers.get("Cookie") ?? "";
27
29
  const theme = readThemeFromCookieHeader(cookie);
28
30
  const locale = getLocale(props.c);
29
- const preferences = props.preferences === undefined ? "bottom-right" : props.preferences;
30
31
  props.c.get("page").theme = theme;
31
32
 
33
+ if (props.preferences === false) {
34
+ return (
35
+ <LocaleProvider locale={locale}>
36
+ <TimezoneCookie />
37
+ {props.children}
38
+ </LocaleProvider>
39
+ );
40
+ }
41
+
42
+ const t = profilePreferencesMessages.resolve([locale]).t;
43
+ // Aggregate every running app's legal links like Layout (last wins on
44
+ // duplicate href). Unlike Layout, MinimalLayout does not require
45
+ // middleware.runtime(); without it the footer has no legal links.
46
+ const apps = resolveAppPresentations(props.c.get("runtime")?.apps ?? [], locale);
47
+ const legalLinks = [...new Map(apps.flatMap((app) => (app.legalLinks ?? []).map((link) => [link.href, link] as const))).values()];
48
+
32
49
  return (
33
50
  <LocaleProvider locale={locale}>
34
51
  <TimezoneCookie />
35
- {props.children}
36
- {preferences !== false && (
37
- <LayoutPreferences
38
- class={`minimal-layout-preferences minimal-layout-preferences--${preferences}`}
39
- initialTheme={theme}
40
- position={menuPosition(preferences)}
41
- triggerClass="minimal-layout-preferences__trigger"
42
- />
43
- )}
52
+ <div class="minimal-layout">
53
+ {props.children}
54
+ <footer class="minimal-layout-footer">
55
+ {legalLinks.length > 0 && (
56
+ <nav class="minimal-layout-footer__links" aria-label={t.legalLinks}>
57
+ {legalLinks.map((link) => (
58
+ // A new tab keeps a started upload or a filled-in form on this page.
59
+ <a href={link.href} target="_blank" rel="noopener">
60
+ {link.label}
61
+ </a>
62
+ ))}
63
+ </nav>
64
+ )}
65
+ <MinimalLayoutPreferences initialTheme={theme} />
66
+ </footer>
67
+ </div>
44
68
  </LocaleProvider>
45
69
  );
46
70
  }
@@ -0,0 +1,48 @@
1
+ import { Button, Dropdown, useLocale } from "@k2b/ui";
2
+ import { type ProfilePreferenceLocale, profilePreferenceLocale, setLocalePreference } from "../browser/locale-preference";
3
+ import { canonicalLocale } from "../shared/locale";
4
+ import type { CloudTheme } from "../shared/theme";
5
+ import { createPreferenceController } from "./preference-controller";
6
+
7
+ /**
8
+ * Labeled language and theme controls for the MinimalLayout footer. The
9
+ * language trigger names the current language; the theme button names the
10
+ * mode it switches to, like every other Cloud preference menu.
11
+ */
12
+ export default function MinimalLayoutPreferences(props: { initialTheme: CloudTheme }) {
13
+ const locale = useLocale();
14
+ const preferences = createPreferenceController(props.initialTheme, locale);
15
+ const t = preferences.messages;
16
+ const language = () => profilePreferenceLocale(locale());
17
+ const languageName = () => (language() === "de" ? t().switchToGerman : t().switchToEnglish);
18
+ // Keep a regional locale such as en-GB when its language is chosen again,
19
+ // but let a visitor on an unsupported locale such as fr-FR, who reads the
20
+ // English fallback, still choose English explicitly.
21
+ const choose = (next: ProfilePreferenceLocale) => {
22
+ if (canonicalLocale(locale())?.split("-")[0] !== next) setLocalePreference(next);
23
+ };
24
+
25
+ return (
26
+ <div class="minimal-layout-footer__preferences">
27
+ <Dropdown.Root
28
+ label={t().language}
29
+ position="top-right"
30
+ width="10rem"
31
+ items={[
32
+ { label: t().switchToGerman, choice: "radio", checked: () => language() === "de", action: () => choose("de") },
33
+ { label: t().switchToEnglish, choice: "radio", checked: () => language() === "en", action: () => choose("en") },
34
+ ]}
35
+ >
36
+ <Dropdown.Trigger label={`${t().language}: ${languageName()}`} size="sm" tooltip={false} variant="ghost">
37
+ <i class="ti ti-language" aria-hidden="true" />
38
+ {languageName()}
39
+ <i class="ti ti-chevron-down" aria-hidden="true" />
40
+ </Dropdown.Trigger>
41
+ </Dropdown.Root>
42
+ <Button onClick={preferences.toggleTheme} size="sm" variant="ghost">
43
+ <i class={preferences.theme() === "light" ? "ti ti-moon" : "ti ti-sun-high"} aria-hidden="true" />
44
+ {preferences.themeLabel()}
45
+ </Button>
46
+ </div>
47
+ );
48
+ }
@@ -17,7 +17,7 @@ export const renderPageError = (c: Context, status: PageErrorStatus, options: Pa
17
17
  return () =>
18
18
  options.layout === "minimal" ? (
19
19
  <MinimalLayout c={c}>
20
- <main class="min-h-screen bg-[var(--ui-canvas)] p-[var(--ui-space-shell)]">
20
+ <main class="flex-1 bg-[var(--ui-canvas)] p-[var(--ui-space-shell)]">
21
21
  <NotFoundState code={String(status)} title={title} description={description} action={action} />
22
22
  </main>
23
23
  </MinimalLayout>
@@ -6,6 +6,8 @@ export const profilePreferencesMessages = i18n.define({
6
6
  en: {
7
7
  menuLabel: "Profile and preferences",
8
8
  preferencesMenuLabel: "Appearance and language",
9
+ language: "Language",
10
+ legalLinks: "Legal",
9
11
  profileSettings: "Profile settings",
10
12
  signOut: "Sign out",
11
13
  signOutFailed: "Sign out failed. Please try again.",
@@ -17,6 +19,8 @@ export const profilePreferencesMessages = i18n.define({
17
19
  de: {
18
20
  menuLabel: "Profil und Einstellungen",
19
21
  preferencesMenuLabel: "Darstellung und Sprache",
22
+ language: "Sprache",
23
+ legalLinks: "Rechtliches",
20
24
  profileSettings: "Profileinstellungen",
21
25
  signOut: "Abmelden",
22
26
  signOutFailed: "Abmelden fehlgeschlagen. Bitte versuche es erneut.",
@@ -903,36 +903,85 @@
903
903
  }
904
904
  }
905
905
 
906
- /* MinimalLayout owns no page geometry. Its optional preference trigger is the
907
- only positioned element and respects device safe areas in every corner. */
908
- .minimal-layout-preferences {
909
- position: fixed;
910
- z-index: 50;
906
+ /* MinimalLayout stacks the page content and its footer in a column at least
907
+ one viewport tall, so the footer ends a short page and follows a long one.
908
+ Content that should fill the remaining height grows with flex-1. */
909
+ .minimal-layout {
910
+ display: flex;
911
+ min-height: 100vh;
912
+ min-height: 100dvh;
913
+ flex-direction: column;
914
+ }
915
+
916
+ .minimal-layout-footer {
917
+ display: flex;
918
+ flex-wrap: wrap;
919
+ align-items: center;
920
+ justify-content: center;
921
+ gap: 0.25rem 1rem;
922
+ margin-top: auto;
923
+ padding: 0.75rem max(1rem, env(safe-area-inset-right)) max(0.75rem, env(safe-area-inset-bottom))
924
+ max(1rem, env(safe-area-inset-left));
925
+ color: var(--k2b-text-muted);
926
+ font-size: 0.75rem;
927
+ line-height: 1rem;
928
+ }
929
+
930
+ .minimal-layout-footer__links,
931
+ .minimal-layout-footer__preferences {
932
+ display: flex;
933
+ flex-wrap: wrap;
934
+ align-items: center;
935
+ justify-content: center;
936
+ }
937
+
938
+ .minimal-layout-footer__links {
939
+ column-gap: 1rem;
911
940
  }
912
941
 
913
- .minimal-layout-preferences--top-left {
914
- top: max(0.75rem, env(safe-area-inset-top));
915
- left: max(0.75rem, env(safe-area-inset-left));
942
+ .minimal-layout-footer__preferences {
943
+ gap: 0.25rem;
916
944
  }
917
945
 
918
- .minimal-layout-preferences--top-right {
919
- top: max(0.75rem, env(safe-area-inset-top));
920
- right: max(0.75rem, env(safe-area-inset-right));
946
+ .minimal-layout-footer__links a {
947
+ display: inline-flex;
948
+ min-height: 1.75rem;
949
+ align-items: center;
950
+ border-radius: var(--k2b-radius-control);
951
+ color: inherit;
952
+ transition: color 150ms ease-out;
953
+ }
954
+
955
+ .minimal-layout-footer__links a:hover {
956
+ color: var(--k2b-text);
957
+ }
958
+
959
+ .minimal-layout-footer__links a:focus-visible {
960
+ outline: 2px solid var(--k2b-focus-ring);
961
+ outline-offset: 2px;
921
962
  }
922
963
 
923
- .minimal-layout-preferences--bottom-left {
924
- bottom: max(0.75rem, env(safe-area-inset-bottom));
925
- left: max(0.75rem, env(safe-area-inset-left));
964
+ .k2b-ui .minimal-layout-footer .k2b-button[data-size] {
965
+ color: inherit;
966
+ font-size: inherit;
967
+ }
968
+
969
+ .k2b-ui .minimal-layout-footer .k2b-button[data-size]:hover {
970
+ color: var(--k2b-text);
926
971
  }
927
972
 
928
- .minimal-layout-preferences--bottom-right {
929
- right: max(0.75rem, env(safe-area-inset-right));
930
- bottom: max(0.75rem, env(safe-area-inset-bottom));
973
+ /* Finger-sized footer targets on touch screens. */
974
+ @media (any-pointer: coarse) {
975
+ .minimal-layout-footer__links a,
976
+ .k2b-ui .minimal-layout-footer .k2b-button[data-size] {
977
+ min-height: 2.75rem;
978
+ }
931
979
  }
932
980
 
933
- .minimal-layout-preferences__trigger {
934
- box-shadow: var(--ui-shadow-float);
935
- backdrop-filter: blur(0.5rem);
981
+ @media (prefers-reduced-motion: reduce) {
982
+ .minimal-layout-footer__links a {
983
+ transition: none;
984
+ }
936
985
  }
937
986
 
938
987
  /* Workspace navigation chrome and resource identity. */