@k2b/cloud 0.22.0 → 0.24.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 (117) hide show
  1. package/package.json +4 -3
  2. package/scripts/README.md +1 -1
  3. package/scripts/build-canvas-workers.ts +58 -0
  4. package/scripts/build.ts +4 -5
  5. package/src/_internal/canvas-worker.ts +24 -0
  6. package/src/_internal/capabilities.ts +230 -75
  7. package/src/_internal/define-app.ts +90 -7
  8. package/src/_internal/fixtures/filesv2-manifest-cloud-v0.29.0.json +887 -0
  9. package/src/_internal/page-responses.ts +20 -4
  10. package/src/_internal/registry-validation.ts +6 -0
  11. package/src/_internal/registry.ts +78 -3
  12. package/src/_internal/runtime-context.ts +1 -0
  13. package/src/_internal/static-assets.ts +18 -3
  14. package/src/access/ResourceApiKeys.tsx +30 -30
  15. package/src/ai/capability-execution.ts +5 -0
  16. package/src/ai/chat/blocks.tsx +2 -1
  17. package/src/ai/chat/messages.ts +6 -0
  18. package/src/ai/client/controller.ts +80 -21
  19. package/src/ai/client/transport.ts +47 -3
  20. package/src/ai/code-mode-skill.ts +2 -2
  21. package/src/ai/index.ts +0 -7
  22. package/src/ai/live-events.ts +1 -262
  23. package/src/ai/live.ts +31 -2
  24. package/src/ai/migrate.ts +35 -16
  25. package/src/ai/pdf-render.ts +2 -22
  26. package/src/ai/routes.ts +5 -4
  27. package/src/ai/runtime.ts +1 -6
  28. package/src/ai/solid.ts +0 -5
  29. package/src/ai/stream.ts +89 -65
  30. package/src/api/app-approval.ts +5 -1
  31. package/src/api/help.ts +2 -2
  32. package/src/api/index.ts +3 -0
  33. package/src/api/me.ts +19 -5
  34. package/src/api/pwa-phone.ts +168 -0
  35. package/src/api/pwa.ts +179 -0
  36. package/src/api/search/schemas.ts +34 -0
  37. package/src/api/search.ts +118 -50
  38. package/src/browser/CloudResourceSearch.browser-harness.tsx +11 -0
  39. package/src/browser/CloudResourceSearch.tsx +208 -80
  40. package/src/browser/app-session.ts +69 -0
  41. package/src/browser/live-connection.ts +211 -0
  42. package/src/browser/live-websocket.ts +31 -11
  43. package/src/browser/live.ts +2 -0
  44. package/src/browser/resource-search-messages.ts +15 -2
  45. package/src/browser/search-stream.ts +151 -0
  46. package/src/contracts/app.ts +10 -0
  47. package/src/contracts/capabilities.ts +89 -24
  48. package/src/contracts/capability-compatibility.ts +28 -14
  49. package/src/contracts/file-provider.ts +158 -0
  50. package/src/contracts/index.ts +2 -0
  51. package/src/contracts/pwa-paths.ts +6 -0
  52. package/src/contracts/pwa.ts +156 -0
  53. package/src/contracts/registry.ts +7 -0
  54. package/src/contracts/shared.ts +7 -1
  55. package/src/events/index.ts +2 -0
  56. package/src/events/live-engine.ts +830 -0
  57. package/src/events/live-protocol.ts +31 -0
  58. package/src/events/live.ts +298 -0
  59. package/src/server/actor.ts +7 -0
  60. package/src/server/api-client.ts +23 -2
  61. package/src/server/help.ts +3 -3
  62. package/src/server/index.ts +3 -1
  63. package/src/server/middleware/auth.ts +80 -8
  64. package/src/server/middleware/openapi.ts +2 -1
  65. package/src/server/services/access.ts +49 -0
  66. package/src/server/services/index.ts +1 -0
  67. package/src/services/app-approval.ts +6 -25
  68. package/src/services/audit/index.ts +2 -0
  69. package/src/services/branding/app-icon-source.ts +40 -0
  70. package/src/services/branding/app-icons.ts +112 -0
  71. package/src/services/branding/icon-render-worker.ts +69 -0
  72. package/src/services/branding/icon-svg.ts +22 -0
  73. package/src/services/identity/invocation-actor.ts +8 -5
  74. package/src/services/identity/invocation-authority.ts +1 -0
  75. package/src/services/identity/invocation-token.ts +6 -0
  76. package/src/services/index.ts +1 -0
  77. package/src/services/outbox.ts +114 -80
  78. package/src/services/pairing-secret.ts +16 -0
  79. package/src/services/pdf/markdown.ts +22 -2
  80. package/src/services/pwa-devices.ts +640 -0
  81. package/src/services/session/index.ts +212 -57
  82. package/src/services/session/recent.ts +42 -0
  83. package/src/services/session/user.ts +15 -5
  84. package/src/shared/index.ts +1 -0
  85. package/src/{browser → shared}/locale-preference.ts +1 -1
  86. package/src/shared/markdown/formula.ts +122 -26
  87. package/src/shared/markdown/index.ts +27 -23
  88. package/src/ssr/AppLaunchpad.island.tsx +8 -1
  89. package/src/ssr/AppLaunchpadPanel.tsx +1 -1
  90. package/src/ssr/GlobalAnnouncements.island.tsx +1 -1
  91. package/src/ssr/Layout.tsx +10 -12
  92. package/src/ssr/LayoutHeader.tsx +14 -2
  93. package/src/ssr/LayoutHelp.tsx +2 -4
  94. package/src/ssr/LayoutRail.tsx +13 -4
  95. package/src/ssr/MinimalLayoutPreferences.island.tsx +1 -1
  96. package/src/ssr/MobileProfileActions.tsx +19 -13
  97. package/src/ssr/PageError.tsx +23 -1
  98. package/src/ssr/ProfilePreferences.island.tsx +9 -1
  99. package/src/ssr/PwaLayout.tsx +70 -0
  100. package/src/ssr/PwaRuntime.island.tsx +105 -0
  101. package/src/ssr/TimezoneCookie.island.tsx +7 -1
  102. package/src/ssr/app-navigation.ts +25 -10
  103. package/src/ssr/index.ts +8 -1
  104. package/src/ssr/layout-context.ts +1 -1
  105. package/src/ssr/preference-controller.ts +2 -2
  106. package/src/ssr/profile-actions.ts +3 -1
  107. package/src/ssr/profile-preferences-messages.ts +2 -0
  108. package/src/ssr/pwa-messages.ts +24 -0
  109. package/src/styles/global.css +6 -0
  110. package/src/styles/resource-search.css +19 -0
  111. package/src/styles/utilities-feedback.css +2 -2
  112. package/scripts/build-pdf-renderer.ts +0 -39
  113. package/src/ai/client/live-connection.ts +0 -141
  114. package/src/ai/live-messages.ts +0 -45
  115. package/src/ai/live-outbox.ts +0 -102
  116. package/src/ai/live-routes.ts +0 -412
  117. package/src/shared/markdown/extensions/info-blocks.ts +0 -108
@@ -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, type NoticeStyle } 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", notices: NoticeStyle = "card", 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", notices: NoticeStyle
33
33
 
34
34
  // Apply extensions in order
35
35
  // Note: katexExtension must come before codeExtension to handle ```math blocks
36
- marked.use(infoBlocksExtension(notices));
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,24 +49,27 @@ const createMarked = (profile: MarkdownProfile = "content", notices: NoticeStyle
49
49
  return marked;
50
50
  };
51
51
 
52
- const marked = createMarked();
53
- const helpMarked = createMarked("help");
54
- const contentMarked = new Map<`${NoticeStyle}:${LinkStyle}`, Marked>([["card:widget", marked]]);
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
- /** `"minimal"` shows notices as tone colour only; the type name stays for screen readers. */
58
- notices?: NoticeStyle;
59
69
  /** `"plain"` renders each link as an ordinary anchor around its text, for HTML read outside Cloud such as email. */
60
70
  links?: LinkStyle;
61
- };
62
-
63
- const markedFor = ({ notices = "card", links = "widget" }: MarkdownRenderOptions): Marked => {
64
- const key = `${notices}:${links}` as const;
65
- const cached = contentMarked.get(key);
66
- if (cached) return cached;
67
- const created = createMarked("content", notices, links);
68
- contentMarked.set(key, created);
69
- return created;
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;
70
73
  };
71
74
 
72
75
  const sanitizeRenderedHtml = (html: string): string =>
@@ -145,7 +148,8 @@ const sanitizeRenderedHtml = (html: string): string =>
145
148
  *
146
149
  * Supported features:
147
150
  * - GFM (GitHub Flavored Markdown)
148
- * - Notice cards (:::note, :::info, :::success, :::warning, :::danger)
151
+ * - Info blocks (:::note, :::info, :::success, :::warning, :::danger), shared
152
+ * with `MarkdownView` from `@k2b/ui`
149
153
  * - Task lists with checkboxes
150
154
  * - Tables with cell formatting
151
155
  * - Styled links and images
@@ -169,7 +173,7 @@ const sanitizeRenderedHtml = (html: string): string =>
169
173
  export function renderMarkdown(content: string, options: MarkdownRenderOptions = {}): string {
170
174
  if (!content || typeof content !== "string") return "";
171
175
 
172
- const html = markedFor(options).parse(content);
176
+ const html = markedFor("content", options.links, options.locale).parse(content);
173
177
  if (typeof html !== "string") return "";
174
178
 
175
179
  return sanitizeRenderedHtml(html);
@@ -181,7 +185,7 @@ export function renderMarkdown(content: string, options: MarkdownRenderOptions =
181
185
  export function renderMarkdownSync(content: string, options: MarkdownRenderOptions = {}): string {
182
186
  if (!content || typeof content !== "string") return "";
183
187
 
184
- const html = markedFor(options).parse(content);
188
+ const html = markedFor("content", options.links, options.locale).parse(content);
185
189
  if (typeof html !== "string") return "";
186
190
 
187
191
  return sanitizeRenderedHtml(html);
@@ -191,9 +195,9 @@ export function renderMarkdownSync(content: string, options: MarkdownRenderOptio
191
195
  * Render trusted documentation Markdown with guided sections and internal
192
196
  * navigation. All fenced code remains visible source, as in ordinary Markdown.
193
197
  */
194
- export function renderHelpMarkdown(content: string): string {
198
+ export function renderHelpMarkdown(content: string, locale?: string): string {
195
199
  if (!content || typeof content !== "string") return "";
196
- const html = helpMarked.parse(content);
200
+ const html = markedFor("help", "widget", locale).parse(content);
197
201
  return typeof html === "string" ? sanitizeRenderedHtml(html) : "";
198
202
  }
199
203
 
@@ -27,7 +27,14 @@ const readEmbeddedContext = (): AppLaunchpadContext | undefined => {
27
27
  if (!Array.isArray(parsed.apps)) return undefined;
28
28
  return {
29
29
  apps: parsed.apps,
30
- profile: parsed.profile?.name && (parsed.profile.theme === "dark" || parsed.profile.theme === "light") ? parsed.profile : undefined,
30
+ profile:
31
+ parsed.profile?.name && (parsed.profile.theme === "dark" || parsed.profile.theme === "light")
32
+ ? {
33
+ name: parsed.profile.name,
34
+ theme: parsed.profile.theme,
35
+ ...(parsed.profile.appHref === "/me/app" ? { appHref: parsed.profile.appHref } : {}),
36
+ }
37
+ : undefined,
31
38
  legalLinks: Array.isArray(parsed.legalLinks) ? parsed.legalLinks : [],
32
39
  };
33
40
  } catch {
@@ -26,7 +26,7 @@ export type AppLaunchpadLegalLink = {
26
26
  };
27
27
 
28
28
  export type AppLaunchpadContext = {
29
- profile?: { name: string; theme: CloudTheme };
29
+ profile?: { name: string; theme: CloudTheme; appHref?: string };
30
30
  apps: AppLaunchpadApp[];
31
31
  legalLinks: AppLaunchpadLegalLink[];
32
32
  };
@@ -102,7 +102,7 @@ export default function GlobalAnnouncements(props: Props) {
102
102
  <MarkdownView
103
103
  trustedHtml={banner.bodyHtml}
104
104
  headingScale="compact"
105
- class="mt-1 max-h-36 overflow-y-auto overscroll-contain pr-1 [&_p]:my-0"
105
+ class="max-h-36 overflow-y-auto overscroll-contain pr-1 [&_p]:my-0"
106
106
  />
107
107
  </NoticeCard>
108
108
  <IconButton size="sm" label={t().dismissBanner} onClick={() => dismissBanner(banner.version)}>
@@ -4,6 +4,7 @@ import type { JSX } from "solid-js/jsx-runtime";
4
4
  import { readAppWorkspaceLayoutCookie, resolveAppWorkspaceLayoutForSidebar } from "../_internal/app-workspace-state";
5
5
  import { navigationSearchItems } from "../browser/navigation-search";
6
6
  import { resolveNavMatch } from "../contracts/app"; // ==========================
7
+ import { isPwaShellAvailable } from "../contracts/pwa";
7
8
  import { defaultRailPreferences } from "../contracts/rail-preferences";
8
9
  import { hasRole, type User } from "../contracts/shared";
9
10
  import { getLocale } from "../server/locale";
@@ -90,9 +91,7 @@ function ProfileWarnings({ user, locale }: { user: User; locale: string }) {
90
91
  if (missing.length === 0) return null;
91
92
  return (
92
93
  <a href="/me" class="block shrink-0 no-underline">
93
- <NoticeCard tone="warning" icon={false} bodyClass="flex items-center gap-2">
94
- <i class="ti ti-user-exclamation" /> <span>{t.profileIncomplete({ fields: missing.join(", ") })}</span>
95
- </NoticeCard>
94
+ <NoticeCard tone="warning">{t.profileIncomplete({ fields: missing.join(", ") })}</NoticeCard>
96
95
  </a>
97
96
  );
98
97
  }
@@ -100,13 +99,12 @@ function ExpiryWarnings({ user, dateConfig, locale }: { user: User; dateConfig:
100
99
  const t = platformMessages.resolve([locale]).t;
101
100
  const now = Date.now();
102
101
  const warnThreshold = now + WARN_DAYS * 24 * 60 * 60 * 1000;
103
- const warnings: { icon: string; message: string; expired: boolean }[] = [];
102
+ const warnings: { message: string; expired: boolean }[] = [];
104
103
  if (user.accountExpires) {
105
104
  const expires = new Date(user.accountExpires).getTime();
106
- if (expires < now) warnings.push({ icon: "ti-calendar-event", message: t.accountExpired, expired: true });
105
+ if (expires < now) warnings.push({ message: t.accountExpired, expired: true });
107
106
  else if (expires < warnThreshold)
108
107
  warnings.push({
109
- icon: "ti-calendar-event",
110
108
  message:
111
109
  user.profile === "guest"
112
110
  ? t.guestAccountExpires({ date: dates.formatDate(user.accountExpires, dateConfig) })
@@ -116,10 +114,9 @@ function ExpiryWarnings({ user, dateConfig, locale }: { user: User; dateConfig:
116
114
  }
117
115
  if (user.ipa?.passwordExpires) {
118
116
  const expires = new Date(user.ipa.passwordExpires).getTime();
119
- if (expires < now) warnings.push({ icon: "ti-key", message: t.passwordExpired, expired: true });
117
+ if (expires < now) warnings.push({ message: t.passwordExpired, expired: true });
120
118
  else if (expires < warnThreshold)
121
119
  warnings.push({
122
- icon: "ti-key",
123
120
  message: t.passwordExpires({ date: dates.formatDate(user.ipa.passwordExpires, dateConfig) }),
124
121
  expired: false,
125
122
  });
@@ -129,10 +126,7 @@ function ExpiryWarnings({ user, dateConfig, locale }: { user: User; dateConfig:
129
126
  <div class="flex shrink-0 flex-col gap-1">
130
127
  {" "}
131
128
  {warnings.map((w) => (
132
- <NoticeCard tone={w.expired ? "danger" : "warning"} icon={false} bodyClass="flex items-center gap-2">
133
- {" "}
134
- <i class={`ti ${w.icon}`} /> <span>{w.message}</span>{" "}
135
- </NoticeCard>
129
+ <NoticeCard tone={w.expired ? "danger" : "warning"}>{w.message}</NoticeCard>
136
130
  ))}{" "}
137
131
  </div>
138
132
  );
@@ -205,6 +199,8 @@ export default function Layout(props: LayoutProps) {
205
199
  if (!page.title) page.title = pageTitle;
206
200
  const breadcrumbs: Breadcrumb[] = !title ? [{ title: appName }] : typeof title === "string" ? [{ title }] : title;
207
201
  const showRail = !!user;
202
+ // The profile menu offers the mobile app only while the installation runs it.
203
+ const appHref = user && isPwaShellAvailable(runtime.apps) ? "/me/app" : undefined;
208
204
  const profileName = user?.displayName || user?.uid || "?";
209
205
  const profileAvatarSrc =
210
206
  user?.avatarHash && user.id
@@ -253,6 +249,7 @@ export default function Layout(props: LayoutProps) {
253
249
  {showRail && (
254
250
  <LayoutRail
255
251
  accent={currentApp?.appearance?.accent}
252
+ appHref={appHref}
256
253
  appsLabel={t.apps}
257
254
  homeLabel={t.home}
258
255
  launchpadApps={launchpadApps}
@@ -270,6 +267,7 @@ export default function Layout(props: LayoutProps) {
270
267
  )}
271
268
  <div class="layout-shell-content flex min-h-0 min-w-0 flex-1 flex-col">
272
269
  <LayoutHeader
270
+ appHref={appHref}
273
271
  appLabel={currentApp?.name ?? (pathname.startsWith("/admin") ? t.admin : undefined)}
274
272
  accent={currentApp?.appearance?.accent}
275
273
  authenticated={showRail}
@@ -12,6 +12,8 @@ import ProfilePreferences from "./ProfilePreferences.island";
12
12
 
13
13
  type LayoutHeaderProps = {
14
14
  accent?: string;
15
+ /** Set while the installation offers the mobile app. */
16
+ appHref?: string;
15
17
  appLabel?: string;
16
18
  authenticated: boolean;
17
19
  breadcrumbs: LayoutBreadcrumb[];
@@ -64,7 +66,11 @@ export default function LayoutHeader(props: LayoutHeaderProps) {
64
66
  />
65
67
  {(props.authenticated || props.appLabel) && (
66
68
  <AppLaunchpad
67
- profile={props.authenticated ? { name: props.profileName, theme: props.theme } : undefined}
69
+ profile={
70
+ props.authenticated
71
+ ? { name: props.profileName, theme: props.theme, ...(props.appHref ? { appHref: props.appHref } : {}) }
72
+ : undefined
73
+ }
68
74
  apps={props.launchpadApps}
69
75
  legalLinks={props.legalLinks}
70
76
  variant="header"
@@ -74,7 +80,13 @@ export default function LayoutHeader(props: LayoutHeaderProps) {
74
80
  </div>
75
81
  {props.authenticated ? (
76
82
  <div class="hidden lg:block">
77
- <ProfilePreferences avatarSrc={props.profileAvatarSrc} initialTheme={props.theme} name={props.profileName} placement="header" />
83
+ <ProfilePreferences
84
+ appHref={props.appHref}
85
+ avatarSrc={props.profileAvatarSrc}
86
+ initialTheme={props.theme}
87
+ name={props.profileName}
88
+ placement="header"
89
+ />
78
90
  </div>
79
91
  ) : (
80
92
  <>
@@ -753,10 +753,8 @@ const HelpShell = (props: {
753
753
  </Show>
754
754
  <Show when={loadError()}>
755
755
  {(message) => (
756
- <NoticeCard tone="danger" icon={false}>
757
- <p class="font-medium">{t().loadFailed}</p>
758
- <p class="mt-1 text-sm">{message()}</p>
759
- <Button size="sm" variant="secondary" class="mt-3" onClick={() => setLoadAttempt((value) => value + 1)}>
756
+ <NoticeCard tone="danger" title={t().loadFailed} detail={message()}>
757
+ <Button size="sm" variant="secondary" onClick={() => setLoadAttempt((value) => value + 1)}>
760
758
  {t().tryAgain}
761
759
  </Button>
762
760
  </NoticeCard>
@@ -12,6 +12,8 @@ export type LayoutAppLink = RailApp;
12
12
 
13
13
  type LayoutRailProps = {
14
14
  accent?: string;
15
+ /** Set while the installation offers the mobile app. */
16
+ appHref?: string;
15
17
  appsLabel: string;
16
18
  homeLabel: string;
17
19
  launchpadApps: AppLaunchpadApp[];
@@ -31,11 +33,12 @@ type LayoutRailProps = {
31
33
  const jsonScript = (value: unknown): string => JSON.stringify(value).replace(/</g, "\\u003c");
32
34
 
33
35
  export default function LayoutRail(props: LayoutRailProps) {
36
+ const profile = { name: props.profileName, theme: props.theme, ...(props.appHref ? { appHref: props.appHref } : {}) };
34
37
  return (
35
38
  <>
36
- <AppLaunchpad profile={{ name: props.profileName, theme: props.theme }} apps={props.launchpadApps} legalLinks={props.legalLinks} />
39
+ <AppLaunchpad profile={profile} apps={props.launchpadApps} legalLinks={props.legalLinks} />
37
40
  <script id="cloud-app-launchpad-data" type="application/json">
38
- {jsonScript({ apps: props.launchpadApps, legalLinks: props.legalLinks, profile: { name: props.profileName, theme: props.theme } })}
41
+ {jsonScript({ apps: props.launchpadApps, legalLinks: props.legalLinks, profile })}
39
42
  </script>
40
43
  <script id="cloud-rail-data" type="application/json">
41
44
  {jsonScript({ apps: props.apps, settings: props.railSettings })}
@@ -49,7 +52,7 @@ export default function LayoutRail(props: LayoutRailProps) {
49
52
  <nav class="layout-rail-navigation flex min-h-0 flex-1 flex-col items-center gap-1" aria-label={props.appsLabel}>
50
53
  <RailApps apps={props.apps} settings={props.railSettings} currentPath={props.currentPath} />
51
54
  <AppLaunchpad
52
- profile={{ name: props.profileName, theme: props.theme }}
55
+ profile={profile}
53
56
  apps={props.launchpadApps}
54
57
  legalLinks={props.legalLinks}
55
58
  variant="rail"
@@ -58,7 +61,13 @@ export default function LayoutRail(props: LayoutRailProps) {
58
61
  <div class="mt-auto shrink-0 flex flex-col items-center gap-1">
59
62
  <GlobalSearchTrigger variant="rail" searchLinks={props.searchLinks} />
60
63
  <LayoutHelpTrigger variant="rail" registerCommand searchHelpApps={props.searchHelpApps} accent={props.accent} />
61
- <ProfilePreferences avatarSrc={props.profileAvatarSrc} initialTheme={props.theme} name={props.profileName} placement="rail" />
64
+ <ProfilePreferences
65
+ appHref={props.appHref}
66
+ avatarSrc={props.profileAvatarSrc}
67
+ initialTheme={props.theme}
68
+ name={props.profileName}
69
+ placement="rail"
70
+ />
62
71
  </div>
63
72
  </nav>
64
73
  </aside>
@@ -1,6 +1,6 @@
1
1
  import { Button, Dropdown, useLocale } from "@k2b/ui";
2
- import { type ProfilePreferenceLocale, profilePreferenceLocale, setLocalePreference } from "../browser/locale-preference";
3
2
  import { canonicalLocale } from "../shared/locale";
3
+ import { type ProfilePreferenceLocale, profilePreferenceLocale, setLocalePreference } from "../shared/locale-preference";
4
4
  import type { CloudTheme } from "../shared/theme";
5
5
  import { createPreferenceController } from "./preference-controller";
6
6
 
@@ -3,24 +3,30 @@ import { Button, ButtonLink } from "@k2b/ui";
3
3
  import type { CloudTheme } from "../shared/theme";
4
4
  import { createProfileActions } from "./profile-actions";
5
5
 
6
- export function MobileProfileActions(props: { profile: { name: string; theme: CloudTheme }; beforeSelect: () => Promise<boolean> }) {
7
- const { preferences, signingOut, signOut } = createProfileActions(props.profile.theme);
6
+ export function MobileProfileActions(props: {
7
+ profile: { name: string; theme: CloudTheme; appHref?: string };
8
+ beforeSelect: () => Promise<boolean>;
9
+ }) {
10
+ const { preferences, signingOut, signOut } = createProfileActions(props.profile.theme, props.profile.appHref);
11
+ const navigate = (href: string) => (event: MouseEvent) => {
12
+ if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
13
+ event.preventDefault();
14
+ void props.beforeSelect().then((allowed) => {
15
+ if (allowed) documentNavigate(href);
16
+ });
17
+ };
8
18
  return (
9
19
  <section class="cloud-mobile-profile" aria-label={preferences.messages().menuLabel}>
10
- <ButtonLink
11
- href="/me"
12
- variant="ghost"
13
- onClick={(event) => {
14
- if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
15
- event.preventDefault();
16
- void props.beforeSelect().then((allowed) => {
17
- if (allowed) documentNavigate("/me");
18
- });
19
- }}
20
- >
20
+ <ButtonLink href="/me" variant="ghost" onClick={navigate("/me")}>
21
21
  <i class="ti ti-user-circle" aria-hidden="true" />
22
22
  {preferences.messages().profileSettings}
23
23
  </ButtonLink>
24
+ {props.profile.appHref && (
25
+ <ButtonLink href={props.profile.appHref} variant="ghost" onClick={navigate(props.profile.appHref)}>
26
+ <i class="ti ti-device-mobile" aria-hidden="true" />
27
+ {preferences.messages().app}
28
+ </ButtonLink>
29
+ )}
24
30
  <Button variant="ghost" onClick={preferences.toggleTheme}>
25
31
  <i class={`ti ${preferences.theme() === "light" ? "ti-moon" : "ti-sun-high"}`} aria-hidden="true" />
26
32
  {preferences.themeLabel()}
@@ -1,10 +1,13 @@
1
- import { NotFoundState } from "@k2b/ui";
1
+ import { ButtonLink, NotFoundState, Placeholder } from "@k2b/ui";
2
2
  import type { Context } from "hono";
3
3
  import type { PageErrorOptions, PageErrorStatus } from "../_internal/page-responses";
4
+ import { PWA_SCOPE } from "../contracts/pwa";
4
5
  import { getLocale } from "../server/locale";
5
6
  import Layout from "./Layout";
6
7
  import MinimalLayout from "./MinimalLayout";
8
+ import PwaLayout from "./PwaLayout";
7
9
  import { pageErrorMessages } from "./page-error-messages";
10
+ import { pwaMessages } from "./pwa-messages";
8
11
 
9
12
  export const renderPageError = (c: Context, status: PageErrorStatus, options: PageErrorOptions) => {
10
13
  const { t } = pageErrorMessages.resolve([getLocale(c)]);
@@ -14,6 +17,25 @@ export const renderPageError = (c: Context, status: PageErrorStatus, options: Pa
14
17
  const action = options.action ?? { label: t.home, href: "/" };
15
18
  c.get("page").title = title;
16
19
 
20
+ if (options.layout === "pwa") {
21
+ const pwa = pwaMessages.resolve([getLocale(c)]).t;
22
+ return () => (
23
+ <PwaLayout c={c} title={title}>
24
+ <Placeholder
25
+ state={status >= 500 ? "error" : "empty"}
26
+ icon={status === 403 ? "ti ti-lock" : status === 404 ? "ti ti-compass-off" : undefined}
27
+ title={title}
28
+ description={description}
29
+ action={
30
+ <ButtonLink href={options.action?.href ?? PWA_SCOPE} variant="secondary">
31
+ {options.action?.label ?? pwa.errorAction}
32
+ </ButtonLink>
33
+ }
34
+ />
35
+ </PwaLayout>
36
+ );
37
+ }
38
+
17
39
  return () =>
18
40
  options.layout === "minimal" ? (
19
41
  <MinimalLayout c={c}>