@k2b/cloud 0.23.0 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/package.json +3 -2
  2. package/src/_internal/capabilities.ts +230 -75
  3. package/src/_internal/define-app.ts +12 -3
  4. package/src/_internal/fixtures/filesv2-manifest-cloud-v0.29.0.json +887 -0
  5. package/src/_internal/page-responses.ts +1 -1
  6. package/src/_internal/registry.ts +78 -3
  7. package/src/access/PermissionEditor.tsx +32 -11
  8. package/src/access/managers.ts +32 -0
  9. package/src/access/messages.ts +6 -0
  10. package/src/ai/chat/blocks.tsx +2 -1
  11. package/src/ai/chat/messages.ts +6 -0
  12. package/src/ai/client/controller.ts +65 -20
  13. package/src/ai/client/transport.ts +5 -5
  14. package/src/ai/code-mode-skill.ts +2 -2
  15. package/src/ai/index.ts +0 -7
  16. package/src/ai/live-events.ts +1 -262
  17. package/src/ai/live.ts +31 -2
  18. package/src/ai/memory-learning.ts +7 -12
  19. package/src/ai/memory-workflow-evidence.ts +10 -2
  20. package/src/ai/migrate.ts +80 -18
  21. package/src/ai/model-access.ts +4 -2
  22. package/src/ai/prefs.ts +19 -5
  23. package/src/ai/projects.ts +5 -2
  24. package/src/ai/runtime.ts +1 -6
  25. package/src/ai/skills.ts +5 -2
  26. package/src/ai/solid.ts +0 -5
  27. package/src/ai/store.ts +14 -0
  28. package/src/api/help.ts +2 -2
  29. package/src/api/pwa-phone.ts +10 -3
  30. package/src/api/search/schemas.ts +34 -0
  31. package/src/api/search.ts +118 -50
  32. package/src/browser/CloudResourceSearch.browser-harness.tsx +11 -0
  33. package/src/browser/CloudResourceSearch.tsx +208 -80
  34. package/src/browser/FileChooser.tsx +600 -0
  35. package/src/browser/choose-files.browser-harness.tsx +32 -0
  36. package/src/browser/choose-files.tsx +70 -0
  37. package/src/browser/file-chooser-messages.ts +86 -0
  38. package/src/browser/file-providers.ts +217 -0
  39. package/src/browser/files.tsx +23 -0
  40. package/src/browser/resource-search-messages.ts +15 -2
  41. package/src/browser/search-stream.ts +151 -0
  42. package/src/cli/admin/account-administration.ts +2 -2
  43. package/src/contracts/capabilities.ts +89 -24
  44. package/src/contracts/capability-compatibility.ts +28 -14
  45. package/src/contracts/file-provider.ts +158 -0
  46. package/src/contracts/index.ts +1 -0
  47. package/src/events/live-engine.ts +11 -7
  48. package/src/events/live.ts +32 -32
  49. package/src/server/help.ts +3 -3
  50. package/src/server/index.ts +1 -0
  51. package/src/server/services/access.ts +44 -11
  52. package/src/server/services/index.ts +1 -0
  53. package/src/services/index.ts +1 -1
  54. package/src/services/outbox.ts +29 -115
  55. package/src/services/pdf/markdown.ts +22 -2
  56. package/src/services/pwa-devices.ts +74 -8
  57. package/src/shared/markdown/formula.ts +122 -26
  58. package/src/shared/markdown/index.ts +27 -14
  59. package/src/ssr/PwaLayout.tsx +2 -1
  60. package/src/ssr/platform-messages.ts +2 -2
  61. package/src/styles/file-chooser.css +169 -0
  62. package/src/styles/global.css +1 -0
  63. package/src/styles/resource-search.css +19 -0
  64. package/src/ai/client/live-connection.ts +0 -141
  65. package/src/ai/live-messages.ts +0 -45
  66. package/src/ai/live-outbox.ts +0 -102
  67. package/src/ai/live-routes.ts +0 -412
  68. package/src/shared/markdown/extensions/info-blocks.ts +0 -79
@@ -475,6 +475,76 @@ const isTruthy = (v: EvalValue): boolean => {
475
475
  return v.length > 0;
476
476
  };
477
477
 
478
+ /** Cell text that is one decimal number and nothing else. Each digit has
479
+ * one place in the pattern, so a long run of digits never backtracks. */
480
+ const NUMBER_CELL = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i;
481
+
482
+ /** A number, boolean, or text whose whole content is one number. */
483
+ const toExactNumber = (v: EvalValue): number | null =>
484
+ typeof v === "string" ? (NUMBER_CELL.test(v.trim()) ? Number(v.trim()) : null) : toNumber(v);
485
+
486
+ /** An ISO date or timestamp: `2026-10-12`, `2026-10-12 09:30`,
487
+ * `2026-10-12T09:30:00.250Z`, or with an offset such as `+02:00`. */
488
+ const ISO_DATE_TIME = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,3})\d*)?)?(Z|[+-]\d{2}:?\d{2})?)?$/i;
489
+
490
+ /** An ISO date without a time, such as `2026-10-12`. */
491
+ const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
492
+
493
+ /**
494
+ * The instant an ISO date or timestamp names, or null for anything else,
495
+ * including impossible dates such as `2026-02-30` and offsets such as
496
+ * `+00:60`. Text without an offset is local time, like the values TODAY()
497
+ * and NOW() return.
498
+ */
499
+ const toDateTime = (v: EvalValue): number | null => {
500
+ if (typeof v !== "string") return null;
501
+ const match = ISO_DATE_TIME.exec(v.trim());
502
+ if (!match) return null;
503
+ const year = Number(match[1]);
504
+ const month = Number(match[2]);
505
+ const day = Number(match[3]);
506
+ const hour = Number(match[4] ?? 0);
507
+ const minute = Number(match[5] ?? 0);
508
+ const second = Number(match[6] ?? 0);
509
+ const ms = Number((match[7] ?? "").padEnd(3, "0"));
510
+ const zone = match[8];
511
+ if (hour > 23 || minute > 59 || second > 59) return null;
512
+ const utc = new Date(0);
513
+ utc.setUTCFullYear(year, month - 1, day);
514
+ if (utc.getUTCMonth() !== month - 1 || utc.getUTCDate() !== day) return null;
515
+ if (zone) {
516
+ utc.setUTCHours(hour, minute, second, ms);
517
+ if (zone.toUpperCase() === "Z") return utc.getTime();
518
+ const sign = zone.startsWith("-") ? -1 : 1;
519
+ const digits = zone.slice(1).replace(":", "");
520
+ const offsetHours = Number(digits.slice(0, 2));
521
+ const offsetMinutes = Number(digits.slice(2));
522
+ if (offsetHours > 23 || offsetMinutes > 59) return null;
523
+ return utc.getTime() - sign * (offsetHours * 60 + offsetMinutes) * 60_000;
524
+ }
525
+ const local = new Date(0);
526
+ local.setFullYear(year, month - 1, day);
527
+ local.setHours(hour, minute, second, ms);
528
+ return local.getTime();
529
+ };
530
+
531
+ /**
532
+ * Equality for `==`, `!=`, COUNTIF, and SUMIF: dates by instant; numbers,
533
+ * also `5 €` against 5, by value; everything else, such as `1.2.3` or
534
+ * `3 apples`, by its exact text.
535
+ */
536
+ const isEqual = (l: EvalValue, r: EvalValue): boolean => {
537
+ const ld = toDateTime(l);
538
+ const rd = toDateTime(r);
539
+ if (ld !== null && rd !== null) return ld === rd;
540
+ if (ld === null && rd === null && (toExactNumber(l) !== null || toExactNumber(r) !== null)) {
541
+ const ln = toNumber(l);
542
+ const rn = toNumber(r);
543
+ if (ln !== null && rn !== null) return ln === rn;
544
+ }
545
+ return toString(l) === toString(r);
546
+ };
547
+
478
548
  const lookupColumn = (name: string, ctx: EvalContext): { index: number } | { suggestion?: string } => {
479
549
  const lower = name.toLowerCase();
480
550
  const idx = ctx.headers.findIndex((h) => h.toLowerCase() === lower);
@@ -492,8 +562,9 @@ const isCurrentFormulaCell = (row: number, col: number, ctx: EvalContext): boole
492
562
  /**
493
563
  * Resolve a single cell to its evaluated value. If the cell is itself
494
564
  * a formula, recursively evaluate it (with cycle detection via
495
- * `ctx._visited`). Non-formula cells are coerced to number when
496
- * possible, otherwise returned as-is.
565
+ * `ctx._visited`). A non-formula cell becomes a number only when its
566
+ * whole text is one; anything else, such as the ISO date `2026-10-12`
567
+ * that DATEDIFF needs intact, stays text.
497
568
  *
498
569
  * Used by both the column-reference resolver in `evaluateAst` and by
499
570
  * the column / row aggregate functions, so a `=SUM(total)` aggregating
@@ -503,8 +574,8 @@ const isCurrentFormulaCell = (row: number, col: number, ctx: EvalContext): boole
503
574
  const evaluateCell = (row: number, col: number, ctx: EvalContext): EvalResult => {
504
575
  const cell = cellAt(row, col, ctx);
505
576
  if (!cell.startsWith("=")) {
506
- const n = toNumber(cell);
507
- return n !== null ? ok(n) : ok(cell);
577
+ const text = cell.trim();
578
+ return NUMBER_CELL.test(text) ? ok(Number(text)) : ok(cell);
508
579
  }
509
580
  const key = `${row},${col}`;
510
581
  if (ctx._visited?.has(key)) {
@@ -686,9 +757,8 @@ const FUNCTIONS: Record<string, FuncImpl> = {
686
757
  if ("kind" in col) return col;
687
758
  const valueArg = state.evaluate(args[1]!);
688
759
  if (valueArg.kind === "error") return valueArg;
689
- const target = toString(valueArg.value);
690
760
  let count = 0;
691
- for (const v of col.values) if (v === target) count++;
761
+ for (const v of col.values) if (isEqual(v, valueArg.value)) count++;
692
762
  return ok(count);
693
763
  },
694
764
  SUMIF: (args, state) => {
@@ -717,13 +787,12 @@ const FUNCTIONS: Record<string, FuncImpl> = {
717
787
  }
718
788
  const condValueArg = state.evaluate(args[2]!);
719
789
  if (condValueArg.kind === "error") return condValueArg;
720
- const target = toString(condValueArg.value);
721
790
  let total = 0;
722
791
  for (let r = 0; r < state.ctx.rows.length; r++) {
723
792
  if (isCurrentFormulaCell(r, condLookup.index, state.ctx) || isCurrentFormulaCell(r, sumLookup.index, state.ctx)) continue;
724
793
  const condResult = evaluateCell(r, condLookup.index, state.ctx);
725
794
  if (condResult.kind !== "ok") continue;
726
- if (toString(condResult.value) !== target) continue;
795
+ if (!isEqual(condResult.value, condValueArg.value)) continue;
727
796
  const sumResult = evaluateCell(r, sumLookup.index, state.ctx);
728
797
  if (sumResult.kind !== "ok") continue;
729
798
  const n = toNumber(sumResult.value);
@@ -989,17 +1058,25 @@ const FUNCTIONS: Record<string, FuncImpl> = {
989
1058
  if (d1V.kind === "error") return d1V;
990
1059
  const d2V = state.evaluate(args[1]!);
991
1060
  if (d2V.kind === "error") return d2V;
992
- const d1 = new Date(toString(d1V.value));
993
- const d2 = new Date(toString(d2V.value));
994
- if (Number.isNaN(d1.getTime())) return err("PARSE_ERROR", `DATEDIFF: first argument is not a valid date`);
995
- if (Number.isNaN(d2.getTime())) return err("PARSE_ERROR", `DATEDIFF: second argument is not a valid date`);
1061
+ // ISO values follow the comparison operators, so a date without a
1062
+ // time is local midnight and an impossible one such as 2026-02-30 is
1063
+ // no date; other text keeps the Date parser's reading.
1064
+ const instant = (v: EvalValue): number =>
1065
+ ISO_DATE_TIME.test(toString(v).trim()) ? (toDateTime(v) ?? Number.NaN) : new Date(toString(v)).getTime();
1066
+ const t1 = instant(d1V.value);
1067
+ const t2 = instant(d2V.value);
1068
+ if (Number.isNaN(t1)) return err("PARSE_ERROR", `DATEDIFF: first argument is not a valid date`);
1069
+ if (Number.isNaN(t2)) return err("PARSE_ERROR", `DATEDIFF: second argument is not a valid date`);
996
1070
  let unit = "days";
997
1071
  if (args.length === 3) {
998
1072
  const u = state.evaluate(args[2]!);
999
1073
  if (u.kind === "error") return u;
1000
1074
  unit = toString(u.value).toLowerCase();
1001
1075
  }
1002
- const diffMs = d2.getTime() - d1.getTime();
1076
+ // Two dates are whole days apart, also across a daylight saving change.
1077
+ const dayMs = 1000 * 60 * 60 * 24;
1078
+ const wholeDays = ISO_DATE.test(toString(d1V.value).trim()) && ISO_DATE.test(toString(d2V.value).trim());
1079
+ const diffMs = wholeDays ? Math.round((t2 - t1) / dayMs) * dayMs : t2 - t1;
1003
1080
  switch (unit) {
1004
1081
  case "ms":
1005
1082
  case "milliseconds":
@@ -1030,18 +1107,45 @@ const FUNCTION_NAMES = Object.keys(FUNCTIONS);
1030
1107
 
1031
1108
  // -- Binop application --------------------------------------------------------
1032
1109
 
1110
+ /**
1111
+ * The two numbers the ordering operators compare, or null when the
1112
+ * values have no common order. Two dates compare as instants and never
1113
+ * with a number or other text, so `2026-10-12` is not `2026`. Text with
1114
+ * a unit, such as `5 €`, compares by its leading number, as does text
1115
+ * against text: `5 €` and `5.00 €` are neither smaller nor greater than
1116
+ * each other, even though `==` tells their texts apart.
1117
+ */
1118
+ const orderedPair = (l: EvalValue, r: EvalValue): [number, number] | null => {
1119
+ const ld = toDateTime(l);
1120
+ const rd = toDateTime(r);
1121
+ if (ld !== null || rd !== null) return ld !== null && rd !== null ? [ld, rd] : null;
1122
+ const ln = toNumber(l);
1123
+ const rn = toNumber(r);
1124
+ return ln !== null && rn !== null ? [ln, rn] : null;
1125
+ };
1126
+
1033
1127
  const applyBinop = (op: BinOp, l: EvalValue, r: EvalValue): EvalResult => {
1034
1128
  if (op === "==" || op === "!=") {
1035
- // Equality: if both look numeric, compare as numbers; else as strings.
1036
- const ln = toNumber(l);
1037
- const rn = toNumber(r);
1038
- const equal = ln !== null && rn !== null ? ln === rn : toString(l) === toString(r);
1129
+ const equal = isEqual(l, r);
1039
1130
  return ok((op === "==" ? equal : !equal) ? 1 : 0);
1040
1131
  }
1132
+ if (op === "<" || op === "<=" || op === ">" || op === ">=") {
1133
+ const pair = orderedPair(l, r);
1134
+ if (pair === null) {
1135
+ return toDateTime(l) !== null || toDateTime(r) !== null
1136
+ ? err("TYPE_ERROR", `Cannot compare a date with a value that is not a date using "${op}"`)
1137
+ : err("NON_NUMERIC", `Cannot compare non-numeric values with "${op}"`);
1138
+ }
1139
+ const [a, b] = pair;
1140
+ if (op === "<") return ok(a < b ? 1 : 0);
1141
+ if (op === "<=") return ok(a <= b ? 1 : 0);
1142
+ if (op === ">") return ok(a > b ? 1 : 0);
1143
+ return ok(a >= b ? 1 : 0);
1144
+ }
1041
1145
  const ln = toNumber(l);
1042
1146
  const rn = toNumber(r);
1043
1147
  if (ln === null || rn === null) {
1044
- return err("NON_NUMERIC", `Cannot compare/compute non-numeric values with "${op}"`);
1148
+ return err("NON_NUMERIC", `Cannot compute non-numeric values with "${op}"`);
1045
1149
  }
1046
1150
  switch (op) {
1047
1151
  case "+":
@@ -1053,14 +1157,6 @@ const applyBinop = (op: BinOp, l: EvalValue, r: EvalValue): EvalResult => {
1053
1157
  case "/":
1054
1158
  if (rn === 0) return err("DIV_BY_ZERO", `Division by zero`);
1055
1159
  return ok(ln / rn);
1056
- case "<":
1057
- return ok(ln < rn ? 1 : 0);
1058
- case "<=":
1059
- return ok(ln <= rn ? 1 : 0);
1060
- case ">":
1061
- return ok(ln > rn ? 1 : 0);
1062
- case ">=":
1063
- return ok(ln >= rn ? 1 : 0);
1064
1160
  }
1065
1161
  };
1066
1162
 
@@ -5,13 +5,13 @@
5
5
  * the visual appearance of the CodeMirror editor extensions.
6
6
  */
7
7
 
8
- import { Marked } from "marked";
8
+ import { markdownInfoBlocks } from "@k2b/ui";
9
+ import { Marked, type MarkedExtension } from "marked";
9
10
  import sanitizeHtml from "sanitize-html";
10
11
  import { markdownClient } from "./client";
11
12
  import { codeExtension } from "./extensions/code";
12
13
  import { guidedHelpExtension } from "./extensions/guided-help";
13
14
  import { imagesExtension } from "./extensions/images";
14
- import { infoBlocksExtension } from "./extensions/info-blocks";
15
15
  import { katexExtension } from "./extensions/katex";
16
16
  import { linksExtension } from "./extensions/links";
17
17
  import { markExtension } from "./extensions/mark";
@@ -23,7 +23,7 @@ import { taskListExtension } from "./extensions/task-list";
23
23
  type MarkdownProfile = "content" | "help";
24
24
  type LinkStyle = "widget" | "plain";
25
25
 
26
- const createMarked = (profile: MarkdownProfile = "content", links: LinkStyle = "widget") => {
26
+ const createMarked = (profile: MarkdownProfile, links: LinkStyle, infoBlocks: MarkedExtension) => {
27
27
  const marked = new Marked();
28
28
 
29
29
  marked.use({
@@ -33,7 +33,7 @@ const createMarked = (profile: MarkdownProfile = "content", links: LinkStyle = "
33
33
 
34
34
  // Apply extensions in order
35
35
  // Note: katexExtension must come before codeExtension to handle ```math blocks
36
- marked.use(infoBlocksExtension());
36
+ marked.use(infoBlocks);
37
37
  marked.use(taskListExtension());
38
38
  marked.use(tablesExtension());
39
39
  // Plain links keep marked's own renderer: an anchor around the link text.
@@ -49,17 +49,29 @@ const createMarked = (profile: MarkdownProfile = "content", links: LinkStyle = "
49
49
  return marked;
50
50
  };
51
51
 
52
- const marked = createMarked();
53
- const helpMarked = createMarked("help");
54
- const plainLinksMarked = createMarked("content", "plain");
52
+ // One instance per UI message catalog, not per requested locale: request
53
+ // headers choose the locale, and `markdownInfoBlocks()` returns one extension
54
+ // for every locale that shares a catalog, so the cache stays bounded.
55
+ const instances = new Map<MarkedExtension, Map<string, Marked>>();
56
+ const markedFor = (profile: MarkdownProfile, links: LinkStyle = "widget", locale?: string): Marked => {
57
+ const infoBlocks = markdownInfoBlocks({ locale });
58
+ const byStyle = instances.get(infoBlocks) ?? new Map<string, Marked>();
59
+ instances.set(infoBlocks, byStyle);
60
+ const key = `${profile}:${links}`;
61
+ const instance = byStyle.get(key) ?? createMarked(profile, links, infoBlocks);
62
+ byStyle.set(key, instance);
63
+ return instance;
64
+ };
65
+
66
+ const marked = markedFor("content");
55
67
 
56
68
  export type MarkdownRenderOptions = {
57
69
  /** `"plain"` renders each link as an ordinary anchor around its text, for HTML read outside Cloud such as email. */
58
70
  links?: LinkStyle;
71
+ /** Request locale for the screen-reader names of untitled info blocks. Defaults to English on the server and the page language in the browser. */
72
+ locale?: string;
59
73
  };
60
74
 
61
- const markedFor = ({ links = "widget" }: MarkdownRenderOptions): Marked => (links === "plain" ? plainLinksMarked : marked);
62
-
63
75
  const sanitizeRenderedHtml = (html: string): string =>
64
76
  sanitizeHtml(html, {
65
77
  allowedTags: [
@@ -136,7 +148,8 @@ const sanitizeRenderedHtml = (html: string): string =>
136
148
  *
137
149
  * Supported features:
138
150
  * - GFM (GitHub Flavored Markdown)
139
- * - Notice cards (:::note, :::info, :::success, :::warning, :::danger)
151
+ * - Info blocks (:::note, :::info, :::success, :::warning, :::danger), shared
152
+ * with `MarkdownView` from `@k2b/ui`
140
153
  * - Task lists with checkboxes
141
154
  * - Tables with cell formatting
142
155
  * - Styled links and images
@@ -160,7 +173,7 @@ const sanitizeRenderedHtml = (html: string): string =>
160
173
  export function renderMarkdown(content: string, options: MarkdownRenderOptions = {}): string {
161
174
  if (!content || typeof content !== "string") return "";
162
175
 
163
- const html = markedFor(options).parse(content);
176
+ const html = markedFor("content", options.links, options.locale).parse(content);
164
177
  if (typeof html !== "string") return "";
165
178
 
166
179
  return sanitizeRenderedHtml(html);
@@ -172,7 +185,7 @@ export function renderMarkdown(content: string, options: MarkdownRenderOptions =
172
185
  export function renderMarkdownSync(content: string, options: MarkdownRenderOptions = {}): string {
173
186
  if (!content || typeof content !== "string") return "";
174
187
 
175
- const html = markedFor(options).parse(content);
188
+ const html = markedFor("content", options.links, options.locale).parse(content);
176
189
  if (typeof html !== "string") return "";
177
190
 
178
191
  return sanitizeRenderedHtml(html);
@@ -182,9 +195,9 @@ export function renderMarkdownSync(content: string, options: MarkdownRenderOptio
182
195
  * Render trusted documentation Markdown with guided sections and internal
183
196
  * navigation. All fenced code remains visible source, as in ordinary Markdown.
184
197
  */
185
- export function renderHelpMarkdown(content: string): string {
198
+ export function renderHelpMarkdown(content: string, locale?: string): string {
186
199
  if (!content || typeof content !== "string") return "";
187
- const html = helpMarked.parse(content);
200
+ const html = markedFor("help", "widget", locale).parse(content);
188
201
  return typeof html === "string" ? sanitizeRenderedHtml(html) : "";
189
202
  }
190
203
 
@@ -48,8 +48,9 @@ export default function PwaLayout(props: PwaLayoutProps) {
48
48
  const path = new URL(c.req.raw.url).pathname;
49
49
  const tabs = parts.slice(0, TAB_PARTS);
50
50
  const currentTab = tabs.find((part) => path === part.pwa.href || path.startsWith(`${part.pwa.href}/`));
51
+ // Start is titled with the installation's name; a part's first page carries the part's name.
51
52
  const items: TabBarItem[] = [
52
- { id: "start", label: t.start, icon: "ti ti-home", href: PWA_SCOPE, current: !currentTab },
53
+ { id: "start", label: t.start, title: cloud, icon: "ti ti-home", href: PWA_SCOPE, current: !currentTab },
53
54
  ...tabs.map((part) => ({ id: part.id, label: part.name, icon: part.icon, href: part.pwa.href, current: part === currentTab })),
54
55
  ];
55
56
 
@@ -72,7 +72,7 @@ export const platformMessages = i18n.define({
72
72
  clearSearch: "Clear search",
73
73
  },
74
74
  de: {
75
- admin: "Verwaltung",
75
+ admin: "Administration",
76
76
  platformAdministration: "Plattform verwalten.",
77
77
  start: "Start",
78
78
  overview: "Übersicht",
@@ -82,7 +82,7 @@ export const platformMessages = i18n.define({
82
82
  appCredentials: "App-Zugänge",
83
83
  ai: "KI",
84
84
  settings: "Einstellungen",
85
- appAdmin: "App-Verwaltung",
85
+ appAdmin: "App-Administration",
86
86
  userManagement: "Accounts & Anmeldung",
87
87
  accountSignIn: "Anmeldung",
88
88
  accountRegistration: "Registrierung & Anfragen",
@@ -0,0 +1,169 @@
1
+ /* The shared file chooser (@k2b/cloud/browser/files). It opens in the workspace
2
+ frame, which takes the whole screen on phones. On wider screens it keeps one
3
+ fixed, smaller size, so moving between sources, folders, and states never
4
+ resizes or moves the frame. Unlayered, like the shared frame rules. */
5
+ @media (min-width: 48rem) {
6
+ .k2b-ui .k2b-dialog.k2b-panel-dialog-frame.cloud-file-chooser-dialog {
7
+ width: min(var(--k2b-dialog-available-width), 44rem);
8
+ height: min(40rem, 86dvh, var(--k2b-dialog-available-height));
9
+ }
10
+ }
11
+
12
+ .cloud-file-chooser {
13
+ display: contents;
14
+ }
15
+
16
+ /* Path and filter sit between header and body with the panel's own inset and
17
+ keep one height in every view. */
18
+ .cloud-file-chooser__toolbar {
19
+ display: flex;
20
+ flex-wrap: wrap;
21
+ align-items: center;
22
+ gap: 0.5rem 0.75rem;
23
+ padding: 0 var(--k2b-panel-dialog-inset) 0.75rem;
24
+ }
25
+
26
+ .cloud-file-chooser__crumbs {
27
+ display: flex;
28
+ flex: 1 1 18rem;
29
+ align-items: center;
30
+ gap: 0.25rem;
31
+ min-width: 0;
32
+ }
33
+
34
+ /* One line: a deep path scrolls sideways instead of wrapping and pushing the list down. */
35
+ .cloud-file-chooser__crumbs ol {
36
+ display: flex;
37
+ align-items: center;
38
+ gap: 0.125rem;
39
+ min-width: 0;
40
+ margin: 0;
41
+ padding: 0;
42
+ overflow-x: auto;
43
+ list-style: none;
44
+ scrollbar-width: none;
45
+ }
46
+
47
+ .cloud-file-chooser__crumbs li {
48
+ display: flex;
49
+ flex: none;
50
+ align-items: center;
51
+ gap: 0.25rem;
52
+ color: var(--k2b-text-muted);
53
+ }
54
+
55
+ .cloud-file-chooser__filter {
56
+ flex: 1 1 14rem;
57
+ max-width: 100%;
58
+ }
59
+
60
+ @media (min-width: 40rem) {
61
+ .cloud-file-chooser__filter {
62
+ flex: 0 0 14rem;
63
+ }
64
+ }
65
+
66
+ .cloud-file-chooser__icon {
67
+ font-size: 1.375rem;
68
+ color: var(--k2b-text-secondary);
69
+ }
70
+
71
+ .cloud-file-chooser__icon[data-selected="true"] {
72
+ color: var(--k2b-action);
73
+ }
74
+
75
+ .cloud-file-chooser__meta {
76
+ display: flex;
77
+ align-items: center;
78
+ gap: 0.375rem;
79
+ overflow: hidden;
80
+ white-space: nowrap;
81
+ }
82
+
83
+ .cloud-file-chooser__tag {
84
+ flex: none;
85
+ }
86
+
87
+ .cloud-file-chooser__state {
88
+ min-height: 100%;
89
+ }
90
+
91
+ .cloud-file-chooser__more {
92
+ display: flex;
93
+ flex-direction: column;
94
+ align-items: flex-start;
95
+ gap: 0.5rem;
96
+ padding: 0.75rem;
97
+ }
98
+
99
+ .cloud-file-chooser__more:empty {
100
+ display: none;
101
+ }
102
+
103
+ .cloud-file-chooser__downloads {
104
+ display: flex;
105
+ flex-direction: column;
106
+ gap: 1rem;
107
+ margin: 0;
108
+ padding: 0.25rem 0;
109
+ list-style: none;
110
+ }
111
+
112
+ .cloud-file-chooser__download-line {
113
+ display: flex;
114
+ align-items: baseline;
115
+ justify-content: space-between;
116
+ gap: 0.75rem;
117
+ margin-bottom: 0.375rem;
118
+ font-size: 0.875rem;
119
+ }
120
+
121
+ .cloud-file-chooser__download-name {
122
+ min-width: 0;
123
+ overflow: hidden;
124
+ text-overflow: ellipsis;
125
+ white-space: nowrap;
126
+ }
127
+
128
+ .cloud-file-chooser__download-state {
129
+ flex: none;
130
+ font-variant-numeric: tabular-nums;
131
+ color: var(--k2b-text-secondary);
132
+ }
133
+
134
+ .cloud-file-chooser__download-state[data-state="error"] {
135
+ color: var(--k2b-danger-text);
136
+ }
137
+
138
+ /* One line that gives way to the buttons: a longer status never wraps the footer and moves the body. */
139
+ .cloud-file-chooser__status {
140
+ flex: 1 1 0;
141
+ min-width: 0;
142
+ overflow: hidden;
143
+ font-size: 0.8125rem;
144
+ line-height: 1.25rem;
145
+ color: var(--k2b-text-secondary);
146
+ text-overflow: ellipsis;
147
+ white-space: nowrap;
148
+ }
149
+
150
+ /* Phones have no room beside the buttons: the status takes its own line above them. The line stays when it is
151
+ empty, so the footer keeps one height in every view. */
152
+ @media (max-width: 39.999rem) {
153
+ .cloud-file-chooser__status {
154
+ flex-basis: 100%;
155
+ min-height: 1.25rem;
156
+ }
157
+ }
158
+
159
+ /* Rows keep their own padding; pulling the list out by it lines icons up with the title and path. */
160
+ .cloud-file-chooser .k2b-file-grid {
161
+ margin-inline: -0.75rem;
162
+ }
163
+
164
+ .cloud-file-chooser__actions {
165
+ display: flex;
166
+ flex: none;
167
+ gap: 0.5rem;
168
+ margin-left: auto;
169
+ }
@@ -30,6 +30,7 @@
30
30
  @import "./effects.css";
31
31
  @import "./input.css";
32
32
  @import "./resource-search.css";
33
+ @import "./file-chooser.css";
33
34
 
34
35
  @theme {
35
36
  --color-dark: rgb(9 9 11);
@@ -214,6 +214,24 @@
214
214
  color: var(--k2b-text-muted);
215
215
  font-size: 0.8125rem;
216
216
  }
217
+ /* Apps still searching and apps that did not answer: one calm line each, below the results. */
218
+ .cloud-resource-search__status {
219
+ display: grid;
220
+ gap: 0.125rem;
221
+ padding: 0 1.125rem 1.5rem;
222
+ color: var(--k2b-text-muted);
223
+ font-size: 0.8125rem;
224
+ }
225
+ .cloud-resource-search__status p {
226
+ display: flex;
227
+ flex-wrap: wrap;
228
+ align-items: center;
229
+ gap: 0.375rem;
230
+ min-height: 1.75rem;
231
+ }
232
+ .cloud-resource-search__status i {
233
+ font-size: 0.875rem;
234
+ }
217
235
  .cloud-resource-search__tag-heading {
218
236
  display: flex;
219
237
  align-items: center;
@@ -386,6 +404,7 @@
386
404
  padding-inline: 0;
387
405
  }
388
406
  .cloud-resource-search__hint,
407
+ .cloud-resource-search__status,
389
408
  .cloud-resource-search__idle,
390
409
  .cloud-resource-search__footer,
391
410
  .cloud-resource-search__count,