@k2b/cloud 0.11.1 → 0.13.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.11.1",
3
+ "version": "0.13.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": {
@@ -31,6 +31,8 @@
31
31
  "./browser": "./src/server/api-client.ts",
32
32
  "./browser/app-approval": "./src/browser/app-approval.ts",
33
33
  "./browser/live": "./src/browser/live-websocket.ts",
34
+ "./browser/mermaid": "./src/browser/mermaid.ts",
35
+ "./browser/reload": "./src/browser/reload.ts",
34
36
  "./browser/notifications": "./src/browser/notifications.ts",
35
37
  "./browser/resource-clipboard": "./src/browser/resource-clipboard.ts",
36
38
  "./browser/commands": "./src/browser/commands.ts",
@@ -84,8 +86,7 @@
84
86
  "./styles/global.css": "./src/styles/global.css"
85
87
  },
86
88
  "scripts": {
87
- "test": "bun test --isolate --path-ignore-patterns '**/*.behavior.test.tsx' --path-ignore-patterns '**/*.dom.test.tsx' && bun run test:dom",
88
- "test:dom": "bun test --isolate --conditions=browser --preload ../ui/test/solid-dom-preload.ts behavior.test.tsx dom.test.tsx",
89
+ "test": "bun test --isolate --path-ignore-patterns '**/*.behavior.test.*'",
89
90
  "typecheck": "tsc -p tsconfig.json --noEmit --pretty false"
90
91
  },
91
92
  "dependencies": {
@@ -98,8 +99,8 @@
98
99
  "@tailwindcss/typography": "0.5.20",
99
100
  "@k2b/nessi": "0.12.1",
100
101
  "@k2b/ssr": "0.14.0",
101
- "@k2b/ui": "0.5.1",
102
- "@k2b/stdlib": "0.25.0",
102
+ "@k2b/ui": "0.6.0",
103
+ "@k2b/stdlib": "0.26.0",
103
104
  "@k2b/sync": "6.5.0",
104
105
  "@nats-io/transport-node": "3.4.0",
105
106
  "@modelcontextprotocol/sdk": "1.30.0",
@@ -109,7 +110,7 @@
109
110
  "katex": "0.18.7",
110
111
  "liquidjs": "10.29.0",
111
112
  "marked": "17.0.6",
112
- "mermaid": "11.16.0",
113
+ "mermaid": "12.0.0",
113
114
  "nodemailer": "10.0.10",
114
115
  "postcss": "8.5.28",
115
116
  "reflect-metadata": "0.2.2",
@@ -3,6 +3,7 @@ import { IconButton, Placeholder, prompts, SelectChip, Tooltip, useLocale } from
3
3
  import { createSignal, For, Show } from "solid-js";
4
4
  import { CloudAvatar } from "../account/Avatar";
5
5
  import type { AccessEntry, PermissionLevel, Principal } from "../contracts/shared";
6
+ import { groupDisplayName } from "../shared/account-display";
6
7
  import { accessMessages } from "./messages";
7
8
  import PrincipalPicker from "./PrincipalPicker";
8
9
 
@@ -112,8 +113,8 @@ const resolveEntryDisplay = (
112
113
  return defaults[permission] ?? defaults.none;
113
114
  };
114
115
 
115
- const getEntryDisplayName = (entry: AccessEntry, t: ReturnType<typeof accessMessages.resolve>["t"]): string => {
116
- if (entry.displayName) return entry.displayName;
116
+ const getEntryDisplayName = (entry: AccessEntry, t: ReturnType<typeof accessMessages.resolve>["t"], locale: string): string => {
117
+ if (entry.displayName) return entry.principal.type === "group" ? groupDisplayName(entry.displayName, locale) : entry.displayName;
117
118
  if (entry.principal.type === "authenticated") return t.allUsers;
118
119
  if (entry.principal.type === "public") return t.public;
119
120
  if (entry.principal.type === "user") return entry.principal.userId;
@@ -184,7 +185,7 @@ export default function PermissionEditor(props: PermissionEditorProps) {
184
185
 
185
186
  const revokeMut = mutation.create<string | null, AccessEntry>({
186
187
  mutation: async (entry) => {
187
- const displayName = getEntryDisplayName(entry, t());
188
+ const displayName = getEntryDisplayName(entry, t(), locale());
188
189
  const confirmed = await prompts.confirm(t().removeAccessConfirm({ name: displayName }), {
189
190
  title: t().removeAccess,
190
191
  variant: "danger",
@@ -263,7 +264,7 @@ function AccessEntryRow(props: {
263
264
  }) {
264
265
  const locale = useLocale();
265
266
  const t = () => accessMessages.resolve([locale()]).t;
266
- const displayName = () => getEntryDisplayName(props.entry, t());
267
+ const displayName = () => getEntryDisplayName(props.entry, t(), locale());
267
268
  const display = () => resolveEntryDisplay(props.entry.permission, props.allowed, t());
268
269
  const isInteractive = () =>
269
270
  props.canEdit && !props.disabled && !props.singlePicker && props.allowed.some((option) => option.level === props.entry.permission);
@@ -1,5 +1,6 @@
1
1
  import { Combobox, type ComboboxOption, useLocale } from "@k2b/ui";
2
2
  import type { Principal } from "../contracts/shared";
3
+ import { groupDisplayName } from "../shared/account-display";
3
4
  import { accessMessages } from "./messages";
4
5
 
5
6
  export const principalKey = (p: Principal) =>
@@ -66,7 +67,7 @@ export default function PrincipalPicker(props: {
66
67
  else if (e.kind === "group")
67
68
  entries.push({
68
69
  principal: { type: "group", groupId: e.group.id },
69
- label: e.group.name,
70
+ label: groupDisplayName(e.group.name, locale()),
70
71
  icon: "ti ti-users-group",
71
72
  description: e.group.description ?? undefined,
72
73
  });
@@ -1,7 +1,8 @@
1
1
  import { timed } from "@k2b/stdlib/solid";
2
- import { Button, ScrollArea, TextInput } from "@k2b/ui";
2
+ import { Button, ScrollArea, TextInput, useLocale } from "@k2b/ui";
3
3
  import type { JSX } from "solid-js";
4
4
  import { createSignal, For, Show } from "solid-js";
5
+ import { groupDisplayName } from "../shared/account-display";
5
6
  import { CloudAvatar } from "./Avatar";
6
7
 
7
8
  /**
@@ -108,6 +109,7 @@ type ApiServiceAccount = {
108
109
  };
109
110
 
110
111
  const EntitySearch = (props: EntitySearchProps) => {
112
+ const locale = useLocale();
111
113
  const [search, setSearch] = createSignal("");
112
114
  const [users, setUsers] = createSignal<ApiUser[]>([]);
113
115
  const [groups, setGroups] = createSignal<ApiGroup[]>([]);
@@ -270,7 +272,7 @@ const EntitySearch = (props: EntitySearchProps) => {
270
272
  {(group) => (
271
273
  <ResultRow
272
274
  icon="ti-users-group"
273
- title={group.name}
275
+ title={groupDisplayName(group.name, locale())}
274
276
  subtitle={group.description ?? undefined}
275
277
  disabled={props.disabled}
276
278
  onSelect={() =>
@@ -339,7 +341,7 @@ const ResultRow = (props: {
339
341
  disabled?: boolean;
340
342
  onSelect: () => void;
341
343
  }) => (
342
- <Button onClick={props.onSelect} disabled={props.disabled} variant="ghost" class="w-full justify-start gap-3 text-left">
344
+ <Button onClick={props.onSelect} disabled={props.disabled} variant="ghost" align="start" class="w-full">
343
345
  <Show
344
346
  when={props.avatar}
345
347
  fallback={
@@ -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": 51,
7
+ "version": 52,
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, 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\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,\ncategory S VAT, units C62/HUR/DAY/KGM. It does not support UBL, XRechnung,\ndiscounts, prepayments or exemptions. 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; vatId: string;\n address: {line1: string; city: string; postalCode: string; countryCode: string};\n};\ntype InvoiceLine = {\n id: string; name: string; description?: string;\n quantity: string; unitPrice: string; unitCode: \"C62\" | \"HUR\" | \"DAY\" | \"KGM\";\n taxRate: string; netAmount?: string;\n};\ntype InvoiceTotals = {\n netAmount: string; taxAmount: string; grossAmount: string; dueAmount: string;\n taxGroups: {taxRate: string; 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; buyer: Party; 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 greater than 0 and 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- `calculate` rounds each line half up to cents, then VAT per 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, account or business reference merely to\nsatisfy input validation.\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 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"
60
60
  },
61
61
  {
62
62
  "path": "references/examples.md",
package/src/ai/store.ts CHANGED
@@ -1095,10 +1095,10 @@ export const aiConversations: AiConversationService = {
1095
1095
  const refs = input.refs ?? [];
1096
1096
  const rows = await withConversationSearchBackend(query, async (backend) => {
1097
1097
  const order = query
1098
- ? sql`${conversationSearchRank(backend, query)} DESC, conversation.pinned_at DESC NULLS LAST, conversation.updated_at DESC, conversation.created_at DESC`
1098
+ ? sql`${conversationSearchRank(backend, query)} DESC, conversation.pinned_at DESC NULLS LAST, conversation.updated_at DESC, conversation.created_at DESC, conversation.id DESC`
1099
1099
  : input.done === true
1100
1100
  ? sql`conversation.last_used_at DESC, conversation.id DESC`
1101
- : sql`conversation.pinned_at DESC NULLS LAST, conversation.updated_at DESC, conversation.created_at DESC`;
1101
+ : sql`conversation.pinned_at DESC NULLS LAST, conversation.updated_at DESC, conversation.created_at DESC, conversation.id DESC`;
1102
1102
  return sql<ConversationRow[]>`
1103
1103
  SELECT
1104
1104
  conversation.*,
@@ -1198,10 +1198,10 @@ export const aiConversations: AiConversationService = {
1198
1198
  const { page, perPage, offset } = sanitizePagination(input);
1199
1199
  const rows = await withConversationSearchBackend(query, async (backend) => {
1200
1200
  const order = query
1201
- ? sql`${conversationSearchRank(backend, query)} DESC, conversation.pinned_at DESC NULLS LAST, conversation.updated_at DESC, conversation.created_at DESC`
1201
+ ? sql`${conversationSearchRank(backend, query)} DESC, conversation.pinned_at DESC NULLS LAST, conversation.updated_at DESC, conversation.created_at DESC, conversation.id DESC`
1202
1202
  : input.done === true
1203
1203
  ? sql`conversation.last_used_at DESC, conversation.id DESC`
1204
- : sql`conversation.pinned_at DESC NULLS LAST, conversation.updated_at DESC, conversation.created_at DESC`;
1204
+ : sql`conversation.pinned_at DESC NULLS LAST, conversation.updated_at DESC, conversation.created_at DESC, conversation.id DESC`;
1205
1205
  return sql<ConversationRow[]>`
1206
1206
  SELECT
1207
1207
  conversation.*,
@@ -41,6 +41,7 @@ const QuerySchema = z
41
41
  parent_group_id: z.uuid().optional(),
42
42
  managed_by_user_id: z.uuid().optional(),
43
43
  recursive: z.enum(["true", "false"]).optional(),
44
+ include_personal: z.enum(["true", "false"]).optional(),
44
45
  })
45
46
  .refine(
46
47
  (value) => {
@@ -98,7 +99,7 @@ export const createAccountsEntitiesRoutes = (dependencies: AccountsEntitiesRoute
98
99
  tags: ["Accounts"],
99
100
  summary: "List mixed users and groups",
100
101
  description:
101
- "List visible users and groups with SQL-backed filtering and pagination. Guest accounts see only themselves and their effective groups; relation filters require a full user account.",
102
+ "List visible users and groups with SQL-backed filtering and pagination. Guest accounts see only themselves and their effective groups; relation filters require a full user account. Personal Linux groups (a user's private primary group, flagged with `personalOwner`) are left out unless `include_personal=true`; exact `group_ids` lookups and relation filters always return them.",
102
103
  ...requiresAuth,
103
104
  responses: {
104
105
  200: jsonResponse(EntitiesListResponseSchema, "Paginated mixed entity list"),
@@ -159,6 +160,7 @@ export const createAccountsEntitiesRoutes = (dependencies: AccountsEntitiesRoute
159
160
  parentGroupId: query.parent_group_id,
160
161
  managedByUserId: query.managed_by_user_id,
161
162
  recursive: query.recursive === "true",
163
+ includePersonal: query.include_personal === "true",
162
164
  });
163
165
 
164
166
  return respond(c, {
@@ -113,7 +113,10 @@ export const createAppApprovalRoutes = (service: Service = appApproval) => {
113
113
  )
114
114
  .post("/login/status", v("json", AppLoginReferenceSchema), async (c) => {
115
115
  const input = c.req.valid("json");
116
- return c.json(await service.browserStatus(input.requestId, input.browserSecret));
116
+ // Hold a pending request for one poll interval: the browser learns a decision at once,
117
+ // keeps its request cadence, and stays below Bun's 10-second idle timeout.
118
+ const hold = { ms: APP_APPROVAL_LIMITS.pollSeconds * 1000, signal: c.req.raw.signal };
119
+ return c.json(await service.browserStatus(input.requestId, input.browserSecret, hold));
117
120
  })
118
121
  .post(
119
122
  "/login/complete",
@@ -52,6 +52,16 @@ const DEFAULT_RECONNECT = {
52
52
  jitterMs: 250,
53
53
  } as const;
54
54
 
55
+ /**
56
+ * Deadline for one opening handshake. The handshake is a single HTTP upgrade
57
+ * round trip (the gateway accepts it before reaching the application), so a
58
+ * healthy attempt finishes far sooner. Browsers can otherwise leave a socket in
59
+ * `CONNECTING` indefinitely after sleep or a network change. Matching the
60
+ * longest default reconnect delay means a stalled attempt never blocks recovery
61
+ * for longer than an ordinary retry.
62
+ */
63
+ const CONNECT_TIMEOUT_MS = DEFAULT_RECONNECT.maxDelayMs;
64
+
55
65
  const defaultCloseError = ({ code, reason }: LiveWebSocketClose): LiveWebSocketError | null => {
56
66
  if (code === 1008) return { code: reason || "access_denied", message: "Live access changed or expired." };
57
67
  if (code === 1011) return { code: reason || "internal_error", message: "Live updates failed." };
@@ -72,7 +82,8 @@ const socketUrl = (raw: string): string => {
72
82
  *
73
83
  * The app owns its wire protocol, validation, permissions, and domain updates.
74
84
  * This helper owns only browser transport lifecycle: one socket, visibility,
75
- * reconnect backoff, applied-cursor resume, terminal closes, and disposal.
85
+ * reconnect backoff, connection deadlines, recovery when the tab or network
86
+ * returns, applied-cursor resume, terminal closes, and disposal.
76
87
  */
77
88
  export const createLiveWebSocket = <TMessage>(options: LiveWebSocketOptions<TMessage>): LiveWebSocket => {
78
89
  const activity = options.activity ?? "visible";
@@ -81,6 +92,8 @@ export const createLiveWebSocket = <TMessage>(options: LiveWebSocketOptions<TMes
81
92
 
82
93
  let socket: WebSocket | null = null;
83
94
  let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
95
+ let connectTimer: ReturnType<typeof setTimeout> | null = null;
96
+ let connectStartedAt = 0;
84
97
  let reconnectAttempt = 0;
85
98
  let lastAppliedCursor = options.initialCursor ?? null;
86
99
  let started = false;
@@ -120,9 +133,15 @@ export const createLiveWebSocket = <TMessage>(options: LiveWebSocketOptions<TMes
120
133
  }
121
134
  };
122
135
 
136
+ const clearConnectDeadline = () => {
137
+ if (connectTimer !== null) clearTimeout(connectTimer);
138
+ connectTimer = null;
139
+ };
140
+
123
141
  const closeSocket = (code = 1000, reason = "") => {
124
142
  const current = socket;
125
143
  socket = null;
144
+ clearConnectDeadline();
126
145
  if (!current || current.readyState > WebSocket.OPEN) return;
127
146
  try {
128
147
  current.close(code, reason);
@@ -135,6 +154,7 @@ export const createLiveWebSocket = <TMessage>(options: LiveWebSocketOptions<TMes
135
154
  if (terminated) return;
136
155
  terminated = true;
137
156
  clearReconnect();
157
+ detachRecovery();
138
158
  closeSocket(close.code, close.reason);
139
159
  setStatus("closed");
140
160
  if (!fatalSent) {
@@ -165,6 +185,13 @@ export const createLiveWebSocket = <TMessage>(options: LiveWebSocketOptions<TMes
165
185
  }, delay);
166
186
  };
167
187
 
188
+ /** Gives up on an attempt that is still connecting and schedules a retry. */
189
+ const abandonAttempt = (attempt: WebSocket) => {
190
+ if (socket !== attempt) return;
191
+ closeSocket();
192
+ scheduleReconnect();
193
+ };
194
+
168
195
  const openSocket = () => {
169
196
  if (disposed || terminated || !started || socket) return;
170
197
  if (!browserAvailable()) {
@@ -174,7 +201,7 @@ export const createLiveWebSocket = <TMessage>(options: LiveWebSocketOptions<TMes
174
201
  if (!isActive()) return;
175
202
 
176
203
  let next: WebSocket;
177
- setStatus(reconnectAttempt > 0 ? "reconnecting" : "connecting");
204
+ setStatus(reconnectAttempt > 0 || status === "reconnecting" ? "reconnecting" : "connecting");
178
205
  try {
179
206
  next = new WebSocket(socketUrl(typeof options.url === "function" ? options.url() : options.url));
180
207
  } catch (error) {
@@ -182,9 +209,15 @@ export const createLiveWebSocket = <TMessage>(options: LiveWebSocketOptions<TMes
182
209
  return;
183
210
  }
184
211
  socket = next;
212
+ connectStartedAt = Date.now();
213
+ connectTimer = setTimeout(() => {
214
+ connectTimer = null;
215
+ if (next.readyState === WebSocket.CONNECTING) abandonAttempt(next);
216
+ }, CONNECT_TIMEOUT_MS);
185
217
 
186
218
  next.onopen = () => {
187
219
  if (socket !== next || disposed || terminated) return;
220
+ clearConnectDeadline();
188
221
  try {
189
222
  if (!send(options.subscribe(lastAppliedCursor))) throw new Error("Live WebSocket subscription could not be sent");
190
223
  setStatus("open");
@@ -213,6 +246,7 @@ export const createLiveWebSocket = <TMessage>(options: LiveWebSocketOptions<TMes
213
246
  next.onclose = (event) => {
214
247
  if (socket !== next) return;
215
248
  socket = null;
249
+ clearConnectDeadline();
216
250
  if (disposed || terminated) return;
217
251
  const closeError = classifyClose({ code: event.code, reason: event.reason.trim() });
218
252
  if (closeError) fatal(closeError, { code: event.code, reason: event.reason });
@@ -224,22 +258,55 @@ export const createLiveWebSocket = <TMessage>(options: LiveWebSocketOptions<TMes
224
258
  next.close();
225
259
  } catch {
226
260
  if (socket === next) {
227
- socket = null;
261
+ closeSocket();
228
262
  scheduleReconnect();
229
263
  }
230
264
  }
231
265
  };
232
266
  };
233
267
 
234
- const syncVisibility = () => {
235
- if (isActive()) openSocket();
236
- else {
268
+ /**
269
+ * Reconnects now when the tab or network returns. Open and closing sockets
270
+ * are left alone; a closing socket's close event still decides between retry
271
+ * and a terminal error. A handshake within its deadline is kept unless the
272
+ * network just returned, because it began on the network that was gone. A
273
+ * stalled handshake or a pending backoff is replaced by an immediate attempt
274
+ * whose backoff starts over.
275
+ */
276
+ const recover = (event: Event) => {
277
+ if (disposed || terminated || !started || !isActive()) return;
278
+ if (socket) {
279
+ if (socket.readyState !== WebSocket.CONNECTING) return;
280
+ if (event.type !== "online" && Date.now() - connectStartedAt < CONNECT_TIMEOUT_MS) return;
281
+ closeSocket();
282
+ }
283
+ clearReconnect();
284
+ reconnectAttempt = 0;
285
+ openSocket();
286
+ };
287
+
288
+ const syncVisibility = (event: Event) => {
289
+ if (isActive()) recover(event);
290
+ else if (activity === "visible") {
237
291
  clearReconnect();
238
292
  closeSocket(1000, "inactive");
239
293
  setStatus("paused");
240
294
  }
241
295
  };
242
296
 
297
+ const attachRecovery = () => {
298
+ document.addEventListener("visibilitychange", syncVisibility);
299
+ window.addEventListener("online", recover);
300
+ window.addEventListener("focus", recover);
301
+ };
302
+
303
+ const detachRecovery = () => {
304
+ if (!started || !browserAvailable()) return;
305
+ document.removeEventListener("visibilitychange", syncVisibility);
306
+ window.removeEventListener("online", recover);
307
+ window.removeEventListener("focus", recover);
308
+ };
309
+
243
310
  return {
244
311
  connect: () => {
245
312
  if (started || disposed || terminated) return;
@@ -248,7 +315,7 @@ export const createLiveWebSocket = <TMessage>(options: LiveWebSocketOptions<TMes
248
315
  fatal({ code: "unsupported", message: "Live WebSockets are not available in this browser" });
249
316
  return;
250
317
  }
251
- if (activity === "visible") document.addEventListener("visibilitychange", syncVisibility);
318
+ attachRecovery();
252
319
  openSocket();
253
320
  },
254
321
  markApplied,
@@ -258,9 +325,7 @@ export const createLiveWebSocket = <TMessage>(options: LiveWebSocketOptions<TMes
258
325
  if (disposed) return;
259
326
  disposed = true;
260
327
  clearReconnect();
261
- if (activity === "visible" && typeof document !== "undefined") {
262
- document.removeEventListener("visibilitychange", syncVisibility);
263
- }
328
+ detachRecovery();
264
329
  closeSocket();
265
330
  setStatus("closed");
266
331
  },
@@ -0,0 +1,202 @@
1
+ import type { MermaidConfig } from "mermaid";
2
+
3
+ /** Series colors shared by pie slices and mindmap branches. */
4
+ const lightSeries = [
5
+ "#dbeafe",
6
+ "#d1fae5",
7
+ "#fef3c7",
8
+ "#ede9fe",
9
+ "#ffe4e6",
10
+ "#cffafe",
11
+ "#ecfccb",
12
+ "#ffedd4",
13
+ "#e0f2fe",
14
+ "#ccfbf1",
15
+ "#fae8ff",
16
+ "#fef9c3",
17
+ ];
18
+ const darkSeries = [
19
+ "#1e3a8a",
20
+ "#065f46",
21
+ "#78350f",
22
+ "#4c1d95",
23
+ "#881337",
24
+ "#155e75",
25
+ "#3f6212",
26
+ "#7c2d12",
27
+ "#075985",
28
+ "#115e59",
29
+ "#86198f",
30
+ "#713f12",
31
+ ];
32
+
33
+ // Mermaid derives colors from hex values, so these mirror the Cloud tokens
34
+ // (zinc ink, --ui-surface*, --ui-border*) instead of reading CSS variables.
35
+ const light = {
36
+ darkMode: false,
37
+ background: "#ffffff",
38
+ textColor: "#27272a",
39
+ titleColor: "#18181b",
40
+ lineColor: "#71717a",
41
+ primaryColor: "#f4f4f5",
42
+ primaryTextColor: "#18181b",
43
+ primaryBorderColor: "#a1a1aa",
44
+ secondaryColor: "#eff6ff",
45
+ secondaryTextColor: "#18181b",
46
+ secondaryBorderColor: "#93c5fd",
47
+ tertiaryColor: "#fafafa",
48
+ tertiaryTextColor: "#18181b",
49
+ tertiaryBorderColor: "#d4d4d8",
50
+ mainBkg: "#f4f4f5",
51
+ nodeBorder: "#a1a1aa",
52
+ clusterBkg: "#fafafa",
53
+ clusterBorder: "#d4d4d8",
54
+ edgeLabelBackground: "#ffffff",
55
+ noteBkgColor: "#fefce8",
56
+ noteBorderColor: "#eab308",
57
+ noteTextColor: "#27272a",
58
+ actorBkg: "#f4f4f5",
59
+ actorBorder: "#a1a1aa",
60
+ actorTextColor: "#18181b",
61
+ signalColor: "#52525b",
62
+ signalTextColor: "#27272a",
63
+ labelBoxBkgColor: "#f4f4f5",
64
+ labelBoxBorderColor: "#a1a1aa",
65
+ activationBkgColor: "#e4e4e7",
66
+ activationBorderColor: "#a1a1aa",
67
+ attributeBackgroundColorOdd: "#ffffff",
68
+ attributeBackgroundColorEven: "#fafafa",
69
+ sectionBkgColor: "#f4f4f5",
70
+ altSectionBkgColor: "#ffffff",
71
+ sectionBkgColor2: "#fafafa",
72
+ gridColor: "#e4e4e7",
73
+ taskBkgColor: "#dbeafe",
74
+ taskBorderColor: "#3b82f6",
75
+ taskTextColor: "#18181b",
76
+ taskTextLightColor: "#18181b",
77
+ taskTextDarkColor: "#18181b",
78
+ taskTextOutsideColor: "#27272a",
79
+ activeTaskBkgColor: "#bfdbfe",
80
+ activeTaskBorderColor: "#2563eb",
81
+ doneTaskBkgColor: "#e4e4e7",
82
+ doneTaskBorderColor: "#a1a1aa",
83
+ critBkgColor: "#ffe4e6",
84
+ critBorderColor: "#e11d48",
85
+ todayLineColor: "#e11d48",
86
+ pieStrokeColor: "#ffffff",
87
+ pieOuterStrokeColor: "#d4d4d8",
88
+ pieSectionTextColor: "#18181b",
89
+ pieTitleTextColor: "#18181b",
90
+ pieLegendTextColor: "#27272a",
91
+ series: lightSeries,
92
+ seriesText: "#18181b",
93
+ };
94
+
95
+ const dark: typeof light = {
96
+ darkMode: true,
97
+ background: "#11151b",
98
+ textColor: "#e4e4e7",
99
+ titleColor: "#f4f4f5",
100
+ lineColor: "#a1a1aa",
101
+ primaryColor: "#20242a",
102
+ primaryTextColor: "#f4f4f5",
103
+ primaryBorderColor: "#5b6573",
104
+ secondaryColor: "#172554",
105
+ secondaryTextColor: "#f4f4f5",
106
+ secondaryBorderColor: "#3b82f6",
107
+ tertiaryColor: "#181d24",
108
+ tertiaryTextColor: "#f4f4f5",
109
+ tertiaryBorderColor: "#3a4552",
110
+ mainBkg: "#20242a",
111
+ nodeBorder: "#5b6573",
112
+ clusterBkg: "#181d24",
113
+ clusterBorder: "#3a4552",
114
+ edgeLabelBackground: "#11151b",
115
+ noteBkgColor: "#422006",
116
+ noteBorderColor: "#a16207",
117
+ noteTextColor: "#fef9c3",
118
+ actorBkg: "#20242a",
119
+ actorBorder: "#5b6573",
120
+ actorTextColor: "#f4f4f5",
121
+ signalColor: "#a1a1aa",
122
+ signalTextColor: "#e4e4e7",
123
+ labelBoxBkgColor: "#20242a",
124
+ labelBoxBorderColor: "#5b6573",
125
+ activationBkgColor: "#29313a",
126
+ activationBorderColor: "#5b6573",
127
+ attributeBackgroundColorOdd: "#181d24",
128
+ attributeBackgroundColorEven: "#20242a",
129
+ sectionBkgColor: "#181d24",
130
+ altSectionBkgColor: "#11151b",
131
+ sectionBkgColor2: "#1c2129",
132
+ gridColor: "#29313a",
133
+ taskBkgColor: "#1e3a8a",
134
+ taskBorderColor: "#60a5fa",
135
+ taskTextColor: "#f4f4f5",
136
+ taskTextLightColor: "#f4f4f5",
137
+ taskTextDarkColor: "#f4f4f5",
138
+ taskTextOutsideColor: "#e4e4e7",
139
+ activeTaskBkgColor: "#1e40af",
140
+ activeTaskBorderColor: "#93c5fd",
141
+ doneTaskBkgColor: "#3f3f46",
142
+ doneTaskBorderColor: "#71717a",
143
+ critBkgColor: "#881337",
144
+ critBorderColor: "#fb7185",
145
+ todayLineColor: "#fb7185",
146
+ pieStrokeColor: "#11151b",
147
+ pieOuterStrokeColor: "#3a4552",
148
+ pieSectionTextColor: "#f4f4f5",
149
+ pieTitleTextColor: "#f4f4f5",
150
+ pieLegendTextColor: "#e4e4e7",
151
+ series: darkSeries,
152
+ seriesText: "#f4f4f5",
153
+ };
154
+
155
+ /**
156
+ * Mermaid configuration that follows Cloud's light or dark tokens.
157
+ *
158
+ * Diagrams keep Mermaid 11's flat look and layouts; an author may still
159
+ * choose `layout: elk` in front matter, which loads ELK on demand. Author
160
+ * directives cannot change security, theme, or CSS.
161
+ */
162
+ export function mermaidConfig(options: { dark: boolean }): MermaidConfig {
163
+ const { series, seriesText, ...palette } = options.dark ? dark : light;
164
+ const themeVariables: Record<string, string | boolean> = {
165
+ ...palette,
166
+ fontFamily: '"IBM Plex Sans", ui-sans-serif, system-ui, sans-serif',
167
+ };
168
+ series.forEach((color, index) => {
169
+ themeVariables[`pie${index + 1}`] = color;
170
+ themeVariables[`cScale${index}`] = color;
171
+ themeVariables[`cScaleLabel${index}`] = seriesText;
172
+ });
173
+ return {
174
+ startOnLoad: false,
175
+ securityLevel: "strict",
176
+ suppressErrorRendering: true,
177
+ theme: "base",
178
+ look: "classic",
179
+ // Mermaid 12 lays these types out with ELK by default. Keep dagre so ELK
180
+ // loads only for diagrams whose front matter asks for `layout: elk`.
181
+ flowchart: { layout: "dagre" },
182
+ class: { layout: "dagre" },
183
+ state: { layout: "dagre" },
184
+ er: { layout: "dagre" },
185
+ requirement: { layout: "dagre" },
186
+ themeVariables,
187
+ secure: [
188
+ "secure",
189
+ "securityLevel",
190
+ "startOnLoad",
191
+ "maxTextSize",
192
+ "maxEdges",
193
+ "suppressErrorRendering",
194
+ "htmlLabels",
195
+ "themeCSS",
196
+ "themeVariables",
197
+ "theme",
198
+ "fontFamily",
199
+ "altFontFamily",
200
+ ],
201
+ };
202
+ }