@k2b/cloud 0.32.0 → 0.34.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 (113) hide show
  1. package/package.json +5 -5
  2. package/src/_internal/app-presentation.ts +40 -11
  3. package/src/_internal/define-app.ts +18 -8
  4. package/src/_internal/page-responses.ts +5 -1
  5. package/src/_internal/registry.ts +10 -1
  6. package/src/_internal/widget-declarations.ts +16 -3
  7. package/src/_internal/widget-settings.ts +93 -0
  8. package/src/ai/capabilities.ts +8 -5
  9. package/src/ai/code-source-contracts.ts +17 -2
  10. package/src/ai/code-source-tools.ts +1 -0
  11. package/src/ai/default-tools.ts +4 -4
  12. package/src/ai/memories.ts +48 -1
  13. package/src/ai/memory-learning.ts +17 -6
  14. package/src/ai/memory-tool.ts +1 -0
  15. package/src/ai/migrate.ts +8 -0
  16. package/src/ai/provider.ts +4 -3
  17. package/src/ai/resource-refs.ts +35 -3
  18. package/src/ai/system-prompt.ts +3 -2
  19. package/src/api/index.ts +2 -0
  20. package/src/api/mcp.ts +48 -17
  21. package/src/api/pwa.ts +7 -2
  22. package/src/api/resource-links.ts +43 -0
  23. package/src/api/widgets.ts +163 -82
  24. package/src/browser/CloudResourceSearch.tsx +6 -6
  25. package/src/browser/command-shortcuts.ts +2 -11
  26. package/src/browser/navigation-search.ts +4 -1
  27. package/src/browser/resource-picker.tsx +1 -0
  28. package/src/browser/resource-search.ts +7 -2
  29. package/src/browser/widgets.ts +23 -5
  30. package/src/capabilities/testing.ts +13 -1
  31. package/src/cli/capabilities.ts +61 -17
  32. package/src/cli/index.ts +2 -0
  33. package/src/cli/resource-links.ts +13 -0
  34. package/src/contracts/app.ts +7 -1
  35. package/src/contracts/capabilities.ts +25 -5
  36. package/src/contracts/registry.ts +3 -1
  37. package/src/contracts/resource-href.ts +7 -0
  38. package/src/contracts/widgets.ts +80 -6
  39. package/src/desktop/index.ts +3 -2
  40. package/src/desktop/solid.tsx +7 -5
  41. package/src/server/widget-request.ts +34 -5
  42. package/src/services/app-approval.ts +4 -2
  43. package/src/services/approvals/index.ts +40 -11
  44. package/src/services/approvals/list.ts +1 -0
  45. package/src/services/approvals/migrate.ts +10 -0
  46. package/src/services/outbox.ts +13 -6
  47. package/src/services/pdf/markdown.ts +11 -7
  48. package/src/services/pwa-devices.ts +68 -15
  49. package/src/shared/ai-platform-prompt.ts +3 -2
  50. package/src/shared/app-presentation.ts +49 -1
  51. package/src/shared/index.ts +1 -0
  52. package/src/shared/markdown/callout-sanitizer.ts +37 -0
  53. package/src/shared/markdown/index.ts +8 -2
  54. package/src/shared/theme.ts +41 -20
  55. package/src/ssr/AnnouncementBanners.tsx +30 -0
  56. package/src/ssr/AppLaunchpadPanel.tsx +2 -2
  57. package/src/ssr/CloudShell.island.tsx +91 -0
  58. package/src/ssr/GlobalSearchTrigger.tsx +51 -0
  59. package/src/ssr/Layout.tsx +58 -99
  60. package/src/ssr/LayoutHeader.tsx +31 -77
  61. package/src/ssr/LayoutHelp.browser-harness.tsx +43 -0
  62. package/src/ssr/LayoutHelp.tsx +319 -238
  63. package/src/ssr/LayoutHelpTrigger.tsx +49 -0
  64. package/src/ssr/LayoutPreferences.tsx +31 -0
  65. package/src/ssr/LayoutRail.tsx +15 -69
  66. package/src/ssr/MinimalLayout.tsx +2 -3
  67. package/src/ssr/MinimalLayoutPreferences.island.tsx +6 -15
  68. package/src/ssr/MobileNavigation.tsx +13 -8
  69. package/src/ssr/MobileProfileActions.tsx +4 -6
  70. package/src/ssr/PageTrailRow.tsx +14 -0
  71. package/src/ssr/{ProfilePreferences.island.tsx → ProfilePreferences.tsx} +8 -5
  72. package/src/ssr/PwaLayout.tsx +0 -2
  73. package/src/ssr/PwaRuntime.island.tsx +1 -1
  74. package/src/ssr/ShellRuntime.island.tsx +166 -0
  75. package/src/ssr/ThemeCycleButton.tsx +46 -0
  76. package/src/ssr/TimezoneCookie.island.tsx +3 -19
  77. package/src/ssr/WorkspaceNavigationProvider.tsx +20 -1
  78. package/src/ssr/announcements-dialog.tsx +66 -0
  79. package/src/ssr/app-appearance.ts +6 -1
  80. package/src/ssr/{AppLaunchpad.island.tsx → app-launchpad.tsx} +42 -62
  81. package/src/ssr/busy-action.ts +19 -0
  82. package/src/ssr/islands/index.ts +9 -6
  83. package/src/ssr/launchpad-surface.tsx +15 -0
  84. package/src/ssr/layout-help-registry.tsx +60 -0
  85. package/src/ssr/layout-help-url.ts +39 -0
  86. package/src/ssr/mobile-navigation.browser-harness.tsx +20 -0
  87. package/src/ssr/platform-messages.ts +0 -8
  88. package/src/ssr/preference-controller.ts +62 -19
  89. package/src/ssr/profile-actions.ts +12 -5
  90. package/src/ssr/profile-preferences-messages.ts +10 -4
  91. package/src/ssr/rail-context.ts +8 -12
  92. package/src/ssr/rail-navigation.ts +3 -0
  93. package/src/ssr/shell-data.tsx +48 -0
  94. package/src/ssr/timezone-cookie.ts +22 -0
  95. package/src/ssr/workspace-navigation.ts +25 -2
  96. package/src/styles/effects.css +13 -3
  97. package/src/styles/global.css +43 -5
  98. package/src/styles/input.css +0 -8
  99. package/src/styles/tokens.css +0 -2
  100. package/src/styles/utilities-navigation.css +10 -0
  101. package/src/workflows/language/compiler.ts +1 -1
  102. package/src/ssr/AppWorkspaceController.island.tsx +0 -21
  103. package/src/ssr/BrowserPushRegistration.island.tsx +0 -12
  104. package/src/ssr/GlobalAnnouncements.island.tsx +0 -117
  105. package/src/ssr/GlobalSearchHelpDialog.tsx +0 -104
  106. package/src/ssr/GlobalSearchTrigger.island.tsx +0 -75
  107. package/src/ssr/LayoutHelpTrigger.island.tsx +0 -61
  108. package/src/ssr/LayoutPreferences.island.tsx +0 -21
  109. package/src/ssr/PageTrailRow.island.tsx +0 -20
  110. package/src/ssr/PageTrailRuntime.island.tsx +0 -26
  111. package/src/ssr/RegisteredHelpDocuments.island.tsx +0 -3
  112. /package/src/ssr/{LayoutBreadcrumbs.island.tsx → LayoutBreadcrumbs.tsx} +0 -0
  113. /package/src/ssr/{RailApps.island.tsx → RailApps.tsx} +0 -0
@@ -1,6 +1,7 @@
1
1
  import { timingSafeEqual } from "node:crypto";
2
2
  import { type SQL, sql } from "bun";
3
3
  import type { Context } from "hono";
4
+ import { lazySync } from "../_internal/process-sync";
4
5
  import { PWA_LIMITS as limits, type PwaDeviceView, type PwaErrorCode, type PwaPairingState, type PwaPlatform } from "../contracts/pwa";
5
6
  import { isAccountCategoryAllowed } from "./account-category-policy";
6
7
  import { audit } from "./audit";
@@ -30,6 +31,13 @@ const fail = (code: PwaErrorCode, status: PwaError["status"], attemptsLeft?: num
30
31
 
31
32
  /** The initiating web session of a pairing. */
32
33
  export type PwaWebActor = { userId: string; sid: string };
34
+ /** Best-effort wake-ups for browsers waiting on a pairing claim. Postgres stays authoritative. */
35
+ export type PwaPairingClaimHints = {
36
+ /** Call after the claim commits. Must not throw or delay the caller. */
37
+ publish(pairingId: string): void;
38
+ /** Settles on the first hint for this pairing, or when `signal` aborts. */
39
+ wait(pairingId: string, signal: AbortSignal): Promise<void>;
40
+ };
33
41
  /** Administration of another account's phones needs current admin authority. */
34
42
  export type PwaDeviceAdministrator = { userId: string; admin: boolean };
35
43
  export type PwaRevocationReason = "user" | "unpaired" | "admin" | "replaced" | "account_expired";
@@ -87,6 +95,25 @@ type UserRow = {
87
95
  };
88
96
 
89
97
  const log = logger("pwa-devices");
98
+ const claimTopic = lazySync((sync) =>
99
+ sync.topic<null>({
100
+ id: "cloud:pwa-pairing-claims",
101
+ owner: "cloud",
102
+ retention: { maxAgeMs: 120_000, maxBytes: 1_048_576 },
103
+ maxPayloadBytes: 2000,
104
+ }),
105
+ );
106
+ /** Core NATS broadcast reaches the waiting browser on any replica. */
107
+ export const syncPairingClaimHints: PwaPairingClaimHints = {
108
+ publish: (pairingId) => {
109
+ void Promise.resolve()
110
+ .then(() => claimTopic().publish({ tenantId: pairingId, data: null }))
111
+ .catch((error) => log.warn("App pairing claim hint failed", { error: error instanceof Error ? error.name : "UnknownError" }));
112
+ },
113
+ wait: async (pairingId, signal) => {
114
+ for await (const _event of claimTopic().live({ tenantId: pairingId, signal })) return;
115
+ },
116
+ };
90
117
  const iso = (value: Date | string) => new Date(value).toISOString();
91
118
  const passed = (value: Date | string) => new Date(value).getTime() <= Date.now();
92
119
  const DEVICE_KEY = /^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.([A-Za-z0-9_-]{43})$/;
@@ -243,7 +270,7 @@ export type PwaCompletion =
243
270
  | { state: "paired"; credentials: PwaCredentials | null; platform: PwaPlatform }
244
271
  | { state: "waiting"; code: string; account: { name: string }; expiresAt: string };
245
272
 
246
- export const createPwaDeviceService = () => {
273
+ export const createPwaDeviceService = (hints: PwaPairingClaimHints = syncPairingClaimHints) => {
247
274
  const appSession = (deviceId: string) => ({ ttlSeconds: limits.sessionSeconds, pwaDeviceId: deviceId, recordLogin: false });
248
275
 
249
276
  /**
@@ -334,21 +361,46 @@ export const createPwaDeviceService = () => {
334
361
  },
335
362
 
336
363
  /** Never returns the code: the person types what the phone shows. */
337
- inspectPairing: async (actor: PwaWebActor, id: string) => {
338
- const [p] = await sql<(PairingRow & { current_epoch: string })[]>`
339
- SELECT p.*, u.auth_epoch AS current_epoch FROM auth.pwa_pairings p JOIN auth.users u ON u.id = p.user_id
340
- WHERE p.id = ${id}::uuid AND p.user_id = ${actor.userId}::uuid AND p.initiator_sid = ${actor.sid}::uuid
341
- `;
342
- if (!p) return fail("NOT_FOUND", 404);
343
- if (passed(p.expires_at) || p.current_epoch !== p.auth_epoch || (p.state === "pending" && passed(p.claim_until)))
344
- return fail("EXPIRED", 410);
345
- return {
346
- state: p.state,
347
- claimUntil: iso(p.claim_until),
348
- expiresAt: iso(p.expires_at),
349
- device: p.state === "pending" || !p.device_name || !p.platform ? null : { name: p.device_name, platform: p.platform },
350
- attemptsLeft: Math.max(0, limits.confirmAttempts - p.failed_attempts),
364
+ inspectPairing: async (actor: PwaWebActor, id: string, hold?: { ms: number; signal?: AbortSignal }) => {
365
+ const read = async (validateInitiator = false) => {
366
+ const [p] = await sql<(PairingRow & { current_epoch: string })[]>`
367
+ SELECT p.*, u.auth_epoch AS current_epoch FROM auth.pwa_pairings p JOIN auth.users u ON u.id = p.user_id
368
+ WHERE p.id = ${id}::uuid AND p.user_id = ${actor.userId}::uuid AND p.initiator_sid = ${actor.sid}::uuid
369
+ `;
370
+ if (!p) return fail("NOT_FOUND", 404);
371
+ if (passed(p.expires_at) || p.current_epoch !== p.auth_epoch || (p.state === "pending" && passed(p.claim_until)))
372
+ return fail("EXPIRED", 410);
373
+ if (validateInitiator && !(await initiatorValid(sql, p))) return fail("EXPIRED", 410);
374
+ return {
375
+ state: p.state,
376
+ claimUntil: iso(p.claim_until),
377
+ expiresAt: iso(p.expires_at),
378
+ device: p.state === "pending" || !p.device_name || !p.platform ? null : { name: p.device_name, platform: p.platform },
379
+ attemptsLeft: Math.max(0, limits.confirmAttempts - p.failed_attempts),
380
+ };
351
381
  };
382
+ if (!hold) return read();
383
+ // Listen before the first read so a claim committed in between usually wakes this request.
384
+ // live() has no subscription-ready barrier, so the hint is best effort; the deadline re-read is the guarantee.
385
+ // The read after the hold also checks the initiating web session, which may have ended meanwhile.
386
+ const waiting = new AbortController();
387
+ const ended = new Promise((resolve) => waiting.signal.addEventListener("abort", resolve, { once: true }));
388
+ const stop = () => waiting.abort();
389
+ hold.signal?.addEventListener("abort", stop, { once: true });
390
+ if (hold.signal?.aborted) stop();
391
+ const hint = hints.wait(id, waiting.signal).catch(() => {});
392
+ let timer: ReturnType<typeof setTimeout> | undefined;
393
+ try {
394
+ const first = await read();
395
+ if (first.state !== "pending") return first;
396
+ timer = setTimeout(stop, Math.min(hold.ms, new Date(first.claimUntil).getTime() - Date.now()));
397
+ await Promise.race([hint, ended]);
398
+ return await read(true);
399
+ } finally {
400
+ clearTimeout(timer);
401
+ hold.signal?.removeEventListener("abort", stop);
402
+ stop();
403
+ }
352
404
  },
353
405
 
354
406
  confirmPairing: async (actor: PwaWebActor, id: string, code: string) => {
@@ -438,6 +490,7 @@ export const createPwaDeviceService = () => {
438
490
  WHERE id = ${p.id}::uuid`;
439
491
  return { account: { name: accountName(user) }, expiresAt: p.expires_at };
440
492
  });
493
+ hints.publish(snapshot.id);
441
494
  return { code, account: result.account, expiresAt: iso(result.expiresAt), completionSecret };
442
495
  },
443
496
 
@@ -105,10 +105,11 @@ Use the conversation file tools for this chat's files: the user's uploads are re
105
105
  {%- if memoryEnabled %}
106
106
 
107
107
  # Personalization rules
108
- Use the dated facts, preferences, and workflow defaults at the end naturally and judge how current they are. A workflow default may select a likely Cloud resource, but never grants access; resolve it through the current authorized capability before use.
108
+ Apply the dated facts, preferences, and workflow defaults at the end only when one changes the answer, and judge how current it is. Do not mention or recite them unless the user asks or one is directly relevant. The current message wins over a conflicting memory. A workflow default may select a likely Cloud resource, but never grants access; resolve it through the current authorized capability before use.
109
109
  {%- if memoryToolEnabled %}
110
110
  - When the user explicitly asks you to remember or forget something, or clearly frames a lasting preference with phrases such as "from now on", "always", or "never", call memory before replying.
111
- - Without a direct request, save only a fact or preference the user clearly stated that is durable and likely useful in future conversations.
111
+ - These limits apply to explicit requests too: never save facts, opinions, or health, relationship, or behavior details about other people, except a named person inside a working rule the user stated. Say what you did not save and offer Contacts or the Project instead.
112
+ - Without a direct request, save only a fact or preference the user clearly stated about themselves that is durable and likely useful in future conversations.
112
113
  - Search before correcting an entry whose id is unknown, update contradictions instead of adding duplicates, and delete wrong or explicitly forgotten memories.
113
114
  - Say you remembered, noted, or forgot something only after the corresponding memory call succeeded.
114
115
  {%- endif %}
@@ -26,7 +26,22 @@ const mergedTranslation = (catalog: AppPresentationCatalog, requestedLocale: str
26
26
  widgets: Object.fromEntries(
27
27
  [...new Set([...Object.keys(current.widgets ?? {}), ...Object.keys(next.widgets ?? {})])].map((id) => [
28
28
  id,
29
- { ...current.widgets?.[id], ...next.widgets?.[id] },
29
+ {
30
+ ...current.widgets?.[id],
31
+ ...next.widgets?.[id],
32
+ settings: Object.fromEntries(
33
+ [...new Set([...Object.keys(current.widgets?.[id]?.settings ?? {}), ...Object.keys(next.widgets?.[id]?.settings ?? {})])].map(
34
+ (name) => [
35
+ name,
36
+ {
37
+ ...current.widgets?.[id]?.settings?.[name],
38
+ ...next.widgets?.[id]?.settings?.[name],
39
+ options: { ...current.widgets?.[id]?.settings?.[name]?.options, ...next.widgets?.[id]?.settings?.[name]?.options },
40
+ },
41
+ ],
42
+ ),
43
+ ),
44
+ },
30
45
  ]),
31
46
  ),
32
47
  };
@@ -65,6 +80,39 @@ export const resolveAppPresentation = <T extends AppMeta>(app: T, requestedLocal
65
80
  ...widget,
66
81
  title: translation.widgets?.[widget.id]?.title ?? widget.title,
67
82
  description: translation.widgets?.[widget.id]?.description ?? widget.description,
83
+ ...(widget.settings && typeof widget.settings === "object" && !Array.isArray(widget.settings)
84
+ ? {
85
+ settings: Object.fromEntries(
86
+ Object.entries(widget.settings).map(([name, field]) => {
87
+ if (!field || typeof field !== "object") return [name, field];
88
+ const translated = translation.widgets?.[widget.id]?.settings?.[name];
89
+ return [
90
+ name,
91
+ {
92
+ ...field,
93
+ label: translated?.label ?? field.label,
94
+ ...(field.kind === "text" ? { placeholder: translated?.placeholder ?? field.placeholder } : {}),
95
+ ...(field.kind === "choice" && Array.isArray(field.options)
96
+ ? {
97
+ options: field.options.map((option) =>
98
+ option && typeof option === "object"
99
+ ? {
100
+ ...option,
101
+ label:
102
+ translated?.options && Object.hasOwn(translated.options, option.value)
103
+ ? (translated.options[option.value] ?? option.label)
104
+ : option.label,
105
+ }
106
+ : option,
107
+ ),
108
+ }
109
+ : {}),
110
+ },
111
+ ];
112
+ }),
113
+ ),
114
+ }
115
+ : {}),
68
116
  })),
69
117
  };
70
118
  };
@@ -44,6 +44,7 @@ export * from "./locale";
44
44
  export * from "./locale-preference";
45
45
  export * from "./login-method";
46
46
  export { markdown } from "./markdown";
47
+ export { markdownCalloutSanitizeOptions } from "./markdown/callout-sanitizer";
47
48
  export type { ErrorCode, EvalContext, EvalError, EvalResult, EvalValue, ProgressValue } from "./markdown/formula";
48
49
  export { createProgressValue, evaluateFormula, formatValue, isFormula, isTotalRow, parseProgressValue } from "./markdown/formula";
49
50
  export type { MockCover, MockCoverIcon, MockCoverOptions, MockCoverTheme } from "./mock-cover";
@@ -0,0 +1,37 @@
1
+ import { MARKDOWN_CALLOUT_ICON_ATTRIBUTES } from "@k2b/ui";
2
+ import type sanitizeHtml from "sanitize-html";
3
+
4
+ /** Fresh sanitize-html options for one document: SVG is limited to fixed callout icons. */
5
+ export const markdownCalloutSanitizeOptions = () => {
6
+ const elements: { inIcon: boolean; discard: boolean }[] = [];
7
+ let closed: { inIcon: boolean; discard: boolean } | undefined;
8
+ return {
9
+ allowedTags: ["svg", "path"],
10
+ allowedAttributes: { svg: Object.keys(MARKDOWN_CALLOUT_ICON_ATTRIBUTES), path: ["d"] },
11
+ // onOpenTag sees raw attributes before transforms; discarded SVG must keep ordinary HTML and text.
12
+ onOpenTag: (tag, attrs) => {
13
+ const parent = elements.at(-1);
14
+ elements.push({
15
+ inIcon: !!parent?.inIcon || (tag === "svg" && attrs.class === MARKDOWN_CALLOUT_ICON_ATTRIBUTES.class),
16
+ discard: !!parent?.discard || (!!parent?.inIcon && tag !== "path"),
17
+ });
18
+ },
19
+ // sanitize-html closes a tag before calling exclusiveFilter for that element.
20
+ onCloseTag: () => {
21
+ closed = elements.pop();
22
+ },
23
+ // excludeTag preserves ordinary content, including after an unclosed author SVG.
24
+ exclusiveFilter: ({ tag }) => closed?.discard || ((tag === "svg" || tag === "path") && !closed?.inIcon ? "excludeTag" : false),
25
+ textFilter: (text) => (elements.at(-1)?.inIcon ? "" : text),
26
+ transformTags: {
27
+ svg: (tagName) => ({
28
+ tagName,
29
+ attribs: { ...MARKDOWN_CALLOUT_ICON_ATTRIBUTES },
30
+ }),
31
+ path: (tagName, attrs) => ({
32
+ tagName,
33
+ attribs: Object.fromEntries(Object.entries(attrs).filter(([name]) => name === "d")),
34
+ }),
35
+ },
36
+ } satisfies sanitizeHtml.IOptions;
37
+ };
@@ -8,6 +8,7 @@
8
8
  import { markdownInfoBlocks } from "@k2b/ui";
9
9
  import { Marked, type MarkedExtension } from "marked";
10
10
  import sanitizeHtml from "sanitize-html";
11
+ import { markdownCalloutSanitizeOptions } from "./callout-sanitizer";
11
12
  import { markdownClient } from "./client";
12
13
  import { codeExtension, plainCodeExtension } from "./extensions/code";
13
14
  import { guidedHelpExtension } from "./extensions/guided-help";
@@ -73,8 +74,10 @@ export type MarkdownRenderOptions = {
73
74
  locale?: string;
74
75
  };
75
76
 
76
- const sanitizeRenderedHtml = (html: string): string =>
77
- sanitizeHtml(html, {
77
+ const sanitizeRenderedHtml = (html: string): string => {
78
+ const callouts = markdownCalloutSanitizeOptions();
79
+ return sanitizeHtml(html, {
80
+ ...callouts,
78
81
  allowedTags: [
79
82
  ...sanitizeHtml.defaults.allowedTags,
80
83
  "annotation",
@@ -101,6 +104,7 @@ const sanitizeRenderedHtml = (html: string): string =>
101
104
  "mtext",
102
105
  "semantics",
103
106
  "span",
107
+ ...callouts.allowedTags,
104
108
  "sub",
105
109
  "sup",
106
110
  "table",
@@ -129,6 +133,7 @@ const sanitizeRenderedHtml = (html: string): string =>
129
133
  input: ["checked", "class", "disabled", "type"],
130
134
  math: ["xmlns"],
131
135
  pre: ["class"],
136
+ ...callouts.allowedAttributes,
132
137
  span: ["aria-hidden", "class", "style", "title"],
133
138
  },
134
139
  allowedSchemes: ["http", "https", "mailto", "tel", "note", "attach"],
@@ -150,6 +155,7 @@ const sanitizeRenderedHtml = (html: string): string =>
150
155
  },
151
156
  },
152
157
  });
158
+ };
153
159
 
154
160
  /**
155
161
  * Render markdown to HTML for server-side display.
@@ -1,10 +1,13 @@
1
1
  export type CloudTheme = "light" | "dark";
2
+ export type CloudThemePreference = CloudTheme | "system";
2
3
 
3
4
  const THEME_COOKIE = "theme";
4
5
  const COOKIE_MAX_AGE_SECONDS = 31536000;
5
6
 
6
7
  const isCloudTheme = (value: string): value is CloudTheme => value === "light" || value === "dark";
7
8
 
9
+ const isCloudThemePreference = (value: string): value is CloudThemePreference => isCloudTheme(value) || value === "system";
10
+
8
11
  const decodeCookieValue = (value: string): string => {
9
12
  try {
10
13
  return decodeURIComponent(value);
@@ -13,28 +16,44 @@ const decodeCookieValue = (value: string): string => {
13
16
  }
14
17
  };
15
18
 
16
- /**
17
- * Resolve the persisted theme from a Cookie header.
18
- *
19
- * Browsers can send duplicate cookie names when legacy path-specific cookies
20
- * exist. RFC ordering puts more specific paths first, so the root preference
21
- * written by the current app is usually the last valid `theme` entry.
22
- */
23
- export const readThemeFromCookieHeader = (cookieHeader: string | null | undefined): CloudTheme => {
24
- let resolved: CloudTheme = "light";
19
+ /** More specific legacy paths arrive first; the root choice is the last valid entry. */
20
+ const readCookieValue = <T extends string>(
21
+ cookieHeader: string | null | undefined,
22
+ name: string,
23
+ valid: (value: string) => value is T,
24
+ ): T | undefined => {
25
+ let result: T | undefined;
25
26
  for (const part of (cookieHeader ?? "").split(";")) {
26
27
  const trimmed = part.trim();
27
28
  const separatorIndex = trimmed.indexOf("=");
28
- if (separatorIndex <= 0) continue;
29
- if (trimmed.slice(0, separatorIndex) !== THEME_COOKIE) continue;
30
-
29
+ if (separatorIndex <= 0 || trimmed.slice(0, separatorIndex) !== name) continue;
31
30
  const value = decodeCookieValue(trimmed.slice(separatorIndex + 1));
32
- if (isCloudTheme(value)) resolved = value;
31
+ if (valid(value)) result = value;
33
32
  }
34
- return resolved;
33
+ return result;
35
34
  };
36
35
 
37
- export const themeBootstrapScript = `!function(){var e=document.documentElement;if(!e.hasAttribute("data-theme-fixed")){var t="light";document.cookie.split(";").forEach(function(e){var r=e.trim(),i=r.indexOf("=");if(i>0&&r.slice(0,i)==="theme"){var o;try{o=decodeURIComponent(r.slice(i+1))}catch(e){o=r.slice(i+1)}(o==="light"||o==="dark")&&(t=o)}});e.classList.remove("light","dark");e.classList.add(t)}}();`;
36
+ /** The person's choice; a missing or invalid cookie means Automatic (`system`). */
37
+ export const readThemePreferenceFromCookieHeader = (cookieHeader: string | null | undefined): CloudThemePreference =>
38
+ readCookieValue(cookieHeader, THEME_COOKIE, isCloudThemePreference) ?? "system";
39
+
40
+ /**
41
+ * The resolved theme for server work such as the AI code runtime. For
42
+ * Automatic it is the scheme the browser last reported; browser code reads the
43
+ * `<html>` class instead.
44
+ */
45
+ export const readThemeFromCookieHeader = (cookieHeader: string | null | undefined): CloudTheme => {
46
+ const preference = readThemePreferenceFromCookieHeader(cookieHeader);
47
+ return preference === "system" ? (readCookieValue(cookieHeader, "color-scheme", isCloudTheme) ?? "light") : preference;
48
+ };
49
+
50
+ /**
51
+ * Resolves the `<html>` class before first paint, because only the browser
52
+ * knows the device scheme, and follows scheme changes live. It also reports
53
+ * the scheme in the `color-scheme` cookie for the server. A constant, so a CSP
54
+ * hash of it stays valid.
55
+ */
56
+ export const themeBootstrapScript = `!function(){var e=document.documentElement;if(e.hasAttribute("data-theme-fixed"))return;var m=matchMedia("(prefers-color-scheme: dark)");function r(n,s){var v;document.cookie.split(";").forEach(function(p){p=p.trim();var i=p.indexOf("=");if(i>0&&p.slice(0,i)===n){var t=p.slice(i+1);try{t=decodeURIComponent(t)}catch(e){}if(t==="light"||t==="dark"||s&&t==="system")v=t}});return v}function a(){var s=m.matches?"dark":"light";if(r("color-scheme")!==s)document.cookie="color-scheme="+s+"; path=/; max-age=31536000; SameSite=Lax"+(location.protocol==="https:"?"; Secure":"");var p=r("theme",true)||"system";e.classList.remove("light","dark");e.classList.add(p==="system"?s:p)}a();m.addEventListener("change",function(){a();window.dispatchEvent(new Event("cloud:theme-preference"))})}();`;
38
57
 
39
58
  const legacyThemeCookiePaths = (pathname: string): string[] => {
40
59
  const paths = new Set(["/", "/me", "/app", "/admin"]);
@@ -48,11 +67,13 @@ const legacyThemeCookiePaths = (pathname: string): string[] => {
48
67
  return [...paths];
49
68
  };
50
69
 
51
- export const setThemePreference = (mode: CloudTheme): CloudTheme => {
70
+ export const setThemePreference = (mode: CloudThemePreference): CloudThemePreference => {
52
71
  if (typeof document === "undefined") return mode;
53
72
 
54
73
  document.documentElement.classList.remove("light", "dark");
55
- document.documentElement.classList.add(mode);
74
+ document.documentElement.classList.add(
75
+ mode === "system" ? (window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light") : mode,
76
+ );
56
77
 
57
78
  const secure = location.protocol === "https:" ? "; Secure" : "";
58
79
  for (const path of legacyThemeCookiePaths(location.pathname)) {
@@ -62,7 +83,7 @@ export const setThemePreference = (mode: CloudTheme): CloudTheme => {
62
83
  return mode;
63
84
  };
64
85
 
65
- export const getCurrentThemePreference = (): CloudTheme => {
66
- if (typeof document === "undefined") return "light";
67
- return document.documentElement.classList.contains("dark") ? "dark" : "light";
86
+ export const getCurrentThemePreference = (): CloudThemePreference => {
87
+ if (typeof document === "undefined") return "system";
88
+ return readThemePreferenceFromCookieHeader(document.cookie);
68
89
  };
@@ -0,0 +1,30 @@
1
+ import { IconButton, MarkdownView, NoticeCard, useLocale } from "@k2b/ui";
2
+ import { For } from "solid-js";
3
+ import type { AnnouncementDisplayEntry } from "../contracts/announcements";
4
+ import { platformMessages } from "./platform-messages";
5
+
6
+ export default function AnnouncementBanners(props: { banners: AnnouncementDisplayEntry[] }) {
7
+ const locale = useLocale();
8
+ const t = () => platformMessages.resolve([locale()]).t;
9
+ if (props.banners.length === 0) return null;
10
+ return (
11
+ <div class="flex shrink-0 flex-col gap-1">
12
+ <For each={props.banners}>
13
+ {(banner) => (
14
+ <div class="cloud-announcement">
15
+ <NoticeCard tone={banner.tone} title={banner.title}>
16
+ <MarkdownView
17
+ trustedHtml={banner.bodyHtml}
18
+ headingScale="compact"
19
+ class="-mx-2 max-h-36 overflow-y-auto overscroll-contain pl-2 pr-3 [&_p]:my-0"
20
+ />
21
+ </NoticeCard>
22
+ <IconButton size="sm" label={t().dismissBanner} data-cloud-announcement-dismiss={banner.version}>
23
+ <i class="ti ti-x" aria-hidden="true" />
24
+ </IconButton>
25
+ </div>
26
+ )}
27
+ </For>
28
+ </div>
29
+ );
30
+ }
@@ -3,7 +3,7 @@ import { IconButton, ScrollArea, TextInput, useLocale } from "@k2b/ui";
3
3
  import { createSignal, For, type JSX, Show } from "solid-js";
4
4
  import { Dynamic } from "solid-js/web";
5
5
  import { importOnDemand } from "../browser/reload";
6
- import type { CloudTheme } from "../shared/theme";
6
+ import type { CloudThemePreference } from "../shared/theme";
7
7
  import { AppBadge, useAppBadgeDescription } from "./AppBadge";
8
8
  import { MobileProfileActions } from "./MobileProfileActions";
9
9
  import { platformMessages } from "./platform-messages";
@@ -29,7 +29,7 @@ export type AppLaunchpadLegalLink = {
29
29
  };
30
30
 
31
31
  export type AppLaunchpadContext = {
32
- profile?: { name: string; theme: CloudTheme; appHref?: string };
32
+ profile?: { name: string; theme: CloudThemePreference; appHref?: string };
33
33
  apps: AppLaunchpadApp[];
34
34
  legalLinks: AppLaunchpadLegalLink[];
35
35
  };
@@ -0,0 +1,91 @@
1
+ import { useLocale } from "@k2b/ui";
2
+ import { createEffect } from "solid-js";
3
+ import { AppLaunchpadButton, setAppLaunchpadContext } from "./app-launchpad";
4
+ import GlobalSearchTrigger from "./GlobalSearchTrigger";
5
+ import LayoutBreadcrumbs from "./LayoutBreadcrumbs";
6
+ import LayoutHelpTrigger from "./LayoutHelpTrigger";
7
+ import LayoutPreferences from "./LayoutPreferences";
8
+ import type { LayoutBreadcrumb } from "./layout-runtime";
9
+ import ProfilePreferences from "./ProfilePreferences";
10
+ import { platformMessages } from "./platform-messages";
11
+ import RailApps from "./RailApps";
12
+ import { useShellData } from "./shell-data";
13
+
14
+ type CloudShellProps =
15
+ | { slot: "rail"; currentPath: string }
16
+ | { slot: "header"; authenticated: boolean; title?: string; launchpad: boolean; breadcrumbs?: LayoutBreadcrumb[] };
17
+
18
+ /**
19
+ * The interactive part of the shell chrome. `LayoutRail` and `LayoutHeader`
20
+ * own the frame and the plain links around it as server markup.
21
+ */
22
+ export default function CloudShell(props: CloudShellProps) {
23
+ return props.slot === "rail" ? <RailNavigation currentPath={props.currentPath} /> : <HeaderControls {...props} />;
24
+ }
25
+
26
+ function RailNavigation(props: { currentPath: string }) {
27
+ const data = useShellData();
28
+ const locale = useLocale();
29
+ const t = () => platformMessages.resolve([locale()]).t;
30
+ if (!data?.rail || !data.profile) return null;
31
+ const profile = { name: data.profile.name, theme: data.theme, ...(data.profile.appHref ? { appHref: data.profile.appHref } : {}) };
32
+ createEffect(() => setAppLaunchpadContext(data.apps, data.legalLinks, profile));
33
+ return (
34
+ <nav class="layout-rail-navigation flex min-h-0 flex-1 flex-col items-center gap-1" aria-label={t().apps}>
35
+ <RailApps apps={data.apps} settings={data.rail} currentPath={props.currentPath} />
36
+ <AppLaunchpadButton profile={profile} apps={data.apps} legalLinks={data.legalLinks} variant="rail" label={t().openApps} />
37
+ <div class="mt-auto shrink-0 flex flex-col items-center gap-1">
38
+ <GlobalSearchTrigger variant="rail" />
39
+ <LayoutHelpTrigger variant="rail" />
40
+ <ProfilePreferences
41
+ appHref={data.profile.appHref}
42
+ avatarSrc={data.profile.avatarSrc}
43
+ initialTheme={data.theme}
44
+ name={data.profile.name}
45
+ />
46
+ </div>
47
+ </nav>
48
+ );
49
+ }
50
+
51
+ /** The header's title and controls, between the server-rendered Home and Sign in. */
52
+ function HeaderControls(props: { authenticated: boolean; title?: string; launchpad: boolean; breadcrumbs?: LayoutBreadcrumb[] }) {
53
+ const data = useShellData();
54
+ const locale = useLocale();
55
+ const t = () => platformMessages.resolve([locale()]).t;
56
+ if (!data) return null;
57
+ return (
58
+ <>
59
+ <div class="ml-2 flex min-w-0 flex-1 items-center">
60
+ {!props.authenticated && (
61
+ <div class="hidden min-w-0 items-center lg:flex">
62
+ <LayoutBreadcrumbs breadcrumbs={props.breadcrumbs ?? []} />
63
+ </div>
64
+ )}
65
+ <div class="flex min-w-0 items-center lg:hidden">
66
+ <span class="truncate font-semibold">{props.title}</span>
67
+ </div>
68
+ </div>
69
+ <div class="flex shrink-0 items-center gap-1">
70
+ <div class="flex items-center gap-1 lg:hidden">
71
+ <LayoutHelpTrigger variant="header" />
72
+ {props.authenticated && <GlobalSearchTrigger variant="header" />}
73
+ {props.launchpad && (
74
+ <AppLaunchpadButton
75
+ profile={
76
+ data.profile
77
+ ? { name: data.profile.name, theme: data.theme, ...(data.profile.appHref ? { appHref: data.profile.appHref } : {}) }
78
+ : undefined
79
+ }
80
+ apps={data.apps}
81
+ legalLinks={data.legalLinks}
82
+ variant="header"
83
+ label={t().openApps}
84
+ />
85
+ )}
86
+ </div>
87
+ {!props.authenticated && <LayoutPreferences initialTheme={data.theme} position="bottom-left" />}
88
+ </div>
89
+ </>
90
+ );
91
+ }
@@ -0,0 +1,51 @@
1
+ import { IconButton, Tooltip, useLocale } from "@k2b/ui";
2
+ import { importOnDemand } from "../browser/reload";
3
+ import { openGlobalSearch } from "../browser/search";
4
+ import { createBusyAction } from "./busy-action";
5
+ import { platformMessages } from "./platform-messages";
6
+
7
+ type GlobalSearchTriggerProps = {
8
+ variant: "header" | "rail";
9
+ class?: string;
10
+ };
11
+
12
+ /** Opens the spotlight-style global search dialog from nav/header trigger points. */
13
+ export default function GlobalSearchTrigger(props: GlobalSearchTriggerProps) {
14
+ const locale = useLocale();
15
+ const t = () => platformMessages.resolve([locale()]).t;
16
+
17
+ const action = createBusyAction();
18
+ const open = () =>
19
+ action.run(
20
+ () => importOnDemand(() => import("./GlobalSearchDialog")),
21
+ () => openGlobalSearch(),
22
+ );
23
+
24
+ if (props.variant === "rail")
25
+ return (
26
+ <Tooltip.Trigger
27
+ type="button"
28
+ class={`rail-item text-blue-500 hover:bg-blue-500/10 hover:text-blue-600 dark:text-blue-400 dark:hover:bg-blue-500/15 dark:hover:text-blue-300 ${props.class ?? ""}`}
29
+ onClick={open}
30
+ aria-busy={action.busy() ? "true" : undefined}
31
+ aria-label={t().openGlobalSearch}
32
+ placement="right"
33
+ delay={0}
34
+ content={t().searchShortcut}
35
+ >
36
+ <i class={`${action.busy() ? "ti ti-loader-2 animate-spin" : "ti ti-search"} text-base`} />
37
+ </Tooltip.Trigger>
38
+ );
39
+
40
+ return (
41
+ <IconButton
42
+ class={props.class}
43
+ onClick={open}
44
+ aria-busy={action.busy() ? "true" : undefined}
45
+ label={t().openGlobalSearch}
46
+ title={t().searchShortcut}
47
+ >
48
+ <i class={`${action.busy() ? "ti ti-loader-2 animate-spin" : "ti ti-search"} text-base`} />
49
+ </IconButton>
50
+ );
51
+ }