@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,26 +1,22 @@
1
1
  import type { RailPreferences, RailSnapshot } from "../contracts/rail-preferences";
2
2
  import type { RailApp } from "./rail-navigation";
3
+ import { readShellData } from "./shell-data";
3
4
 
4
5
  export type RailContext = { apps: RailApp[]; settings: RailSnapshot };
5
6
  export const RAIL_PREFERENCES_EVENT = "cloud:rail-preferences";
6
7
 
7
8
  export const readRailContext = (): RailContext | undefined => {
8
- if (typeof document === "undefined") return undefined;
9
- const text = document.getElementById("cloud-rail-data")?.textContent;
10
- if (!text) return undefined;
11
- // Framework-generated, escaped SSR data; never loaded from localStorage. The server validates the snapshot when
12
- // it reads it, so the shell does not load the schemas on every page.
13
- const value: RailContext = JSON.parse(text);
14
- return { apps: value.apps, settings: value.settings };
9
+ const data = readShellData();
10
+ return data?.rail ? { apps: data.apps, settings: data.rail } : undefined;
15
11
  };
16
12
 
17
13
  export const publishRailPreferences = (settings: RailPreferences) => {
18
- const context = readRailContext();
19
- const element = document.getElementById("cloud-rail-data");
20
- if (context && element)
14
+ const data = readShellData();
15
+ const element = document.getElementById("cloud-shell-data");
16
+ if (data?.rail && element)
21
17
  element.textContent = JSON.stringify({
22
- ...context,
23
- settings: { ...settings, managedShortcuts: context.settings.managedShortcuts },
18
+ ...data,
19
+ rail: { ...settings, managedShortcuts: data.rail.managedShortcuts },
24
20
  }).replace(/</g, "\\u003c");
25
21
  window.dispatchEvent(new CustomEvent(RAIL_PREFERENCES_EVENT));
26
22
  };
@@ -1,4 +1,5 @@
1
1
  import type { RailPreferences, RailSnapshot } from "../contracts/rail-preferences";
2
+ import { helpPageAppId } from "./layout-help-url";
2
3
 
3
4
  export type RailApp = {
4
5
  id: string;
@@ -55,6 +56,8 @@ export const projectRailNavigation = (apps: readonly RailApp[], settings: RailSn
55
56
  /** `currentPath` is the route as `pathname + search`; an absolute URL is accepted but never required. */
56
57
  export const railLinkActive = (link: RailLink, currentPath: string): boolean => {
57
58
  const current = new URL(currentPath, "https://cloud.invalid");
59
+ const helpAppId = helpPageAppId(current.pathname);
60
+ if (helpAppId && link.appId) return link.appId === helpAppId;
58
61
  const target = new URL(link.match, current);
59
62
  if (target.origin !== current.origin) return false;
60
63
  if (link.exact) return target.pathname === current.pathname && target.search === current.search && target.hash === current.hash;
@@ -0,0 +1,48 @@
1
+ import { createContext, type JSX, useContext } from "solid-js";
2
+ import type { AppSearchLink } from "../contracts/app";
3
+ import type { RailSnapshot } from "../contracts/rail-preferences";
4
+ import type { CloudThemePreference } from "../shared/theme";
5
+ import type { AppLaunchpadLegalLink } from "./app-launchpad";
6
+ import type { RailApp } from "./rail-navigation";
7
+
8
+ export type ShellData = {
9
+ /** The authorized navigation catalog, sorted for the request locale: the rail, the app grid and the phone menu show it. */
10
+ apps: RailApp[];
11
+ /** Signed-in pages: the person's rail preferences. */
12
+ rail?: RailSnapshot;
13
+ legalLinks: AppLaunchpadLegalLink[];
14
+ /** Signed-in pages: the person, for the profile menu and the phone menu's profile actions. */
15
+ profile?: { name: string; avatarSrc?: string; appHref?: string };
16
+ theme: CloudThemePreference;
17
+ /** The current app's accent, for the Help dialog and the phone menu, which open outside the app canvas. */
18
+ accent?: string;
19
+ /** The search destinations of each app in `apps`, by app id in registry order; the search host expands them into rows. */
20
+ searchLinks: Record<string, readonly AppSearchLink[]>;
21
+ /** The current app, for links inside its Help. */
22
+ appId?: string;
23
+ };
24
+
25
+ const ShellDataContext = createContext<() => ShellData>();
26
+
27
+ export function ShellDataProvider(props: { data: ShellData; children: JSX.Element }) {
28
+ return (
29
+ <ShellDataContext.Provider value={() => props.data}>
30
+ <script id="cloud-shell-data" type="application/json" innerHTML={JSON.stringify(props.data).replace(/</g, "\\u003c")} />
31
+ {props.children}
32
+ </ShellDataContext.Provider>
33
+ );
34
+ }
35
+
36
+ export function readShellData(): ShellData | undefined {
37
+ if (typeof document === "undefined") return undefined;
38
+ const text = document.getElementById("cloud-shell-data")?.textContent;
39
+ if (!text) return undefined;
40
+ // Framework-generated, escaped SSR data; never loaded from localStorage. The server validates the rail snapshot when
41
+ // it reads it, so the shell does not load the schemas on every page.
42
+ const data: ShellData = JSON.parse(text);
43
+ return data;
44
+ }
45
+
46
+ export function useShellData(): ShellData | undefined {
47
+ return useContext(ShellDataContext)?.() ?? readShellData();
48
+ }
@@ -0,0 +1,22 @@
1
+ import { cookies } from "@k2b/stdlib/browser";
2
+ import { TIMEZONE_COOKIE } from "../shared/time";
3
+
4
+ /**
5
+ * Stores the browser's timezone for server-rendered times. With `reload`, a
6
+ * changed timezone reloads the page once, so it shows times in that zone.
7
+ */
8
+ export const syncTimezoneCookie = (reload: boolean) => {
9
+ const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
10
+ const current = cookies.readCookie(TIMEZONE_COOKIE);
11
+ if (!timeZone || current === timeZone) return;
12
+ cookies.writeCookie(TIMEZONE_COOKIE, timeZone);
13
+ if (!reload) return;
14
+ try {
15
+ if (sessionStorage.getItem("cloud.timezone.reload") !== timeZone) {
16
+ sessionStorage.setItem("cloud.timezone.reload", timeZone);
17
+ window.location.reload();
18
+ }
19
+ } catch {
20
+ // Cookie persistence still succeeds; the next navigation will render with the browser timezone.
21
+ }
22
+ };
@@ -1,4 +1,4 @@
1
- import { createNavigation, type NavigationController, type NavigationItem } from "@k2b/ui";
1
+ import { type AppWorkspaceSidebarSwitcherProps, createNavigation, type NavigationController, type NavigationItem } from "@k2b/ui";
2
2
  import { createEffect, onCleanup, onMount } from "solid-js";
3
3
 
4
4
  export type WorkspaceNavigationState = { navigation: NavigationController; label: string; owner: Element };
@@ -44,6 +44,28 @@ function validItems(value: unknown): value is NavigationItem[] {
44
44
  );
45
45
  }
46
46
 
47
+ function readSwitcher(value: unknown): AppWorkspaceSidebarSwitcherProps | undefined {
48
+ if (
49
+ !value ||
50
+ typeof value !== "object" ||
51
+ !("currentId" in value) ||
52
+ typeof value.currentId !== "string" ||
53
+ !("label" in value) ||
54
+ typeof value.label !== "string" ||
55
+ !("icon" in value) ||
56
+ typeof value.icon !== "string" ||
57
+ !("switchLabel" in value) ||
58
+ typeof value.switchLabel !== "string"
59
+ )
60
+ return;
61
+ const description = "description" in value ? value.description : undefined;
62
+ const color = "color" in value ? value.color : undefined;
63
+ if (description !== undefined && typeof description !== "string") return;
64
+ if (color !== undefined && typeof color !== "string") return;
65
+ // The snapshot carries only the head; options and actions wait for the owning island.
66
+ return { currentId: value.currentId, label: value.label, icon: value.icon, switchLabel: value.switchLabel, description, color };
67
+ }
68
+
47
69
  export function readWorkspaceNavigation(): WorkspaceNavigationState | undefined {
48
70
  if (typeof window === "undefined") return;
49
71
  const current = window.__cloudWorkspaceNavigation;
@@ -67,10 +89,11 @@ export function readWorkspaceNavigation(): WorkspaceNavigationState | undefined
67
89
  ...(item.inlineActions ? { inlineActions: item.inlineActions.map((action) => ({ ...action, disabled: true })) } : {}),
68
90
  }));
69
91
  const items = links(value.items);
92
+ const switcher = "switcher" in value ? readSwitcher(value.switcher) : undefined;
70
93
  return {
71
94
  owner,
72
95
  label: "label" in value && typeof value.label === "string" ? value.label : "",
73
- navigation: createNavigation({ items: () => items }),
96
+ navigation: createNavigation({ items: () => items, ...(switcher ? { switcher: () => switcher } : {}) }),
74
97
  };
75
98
  } catch {
76
99
  return;
@@ -345,12 +345,22 @@
345
345
  background: color-mix(in srgb, var(--app-accent, var(--color-blue-500)) 10%, var(--ui-surface-muted));
346
346
  }
347
347
 
348
+ /* The reader's layout steps follow its own width: a dialog, a full page, or
349
+ an app's embedded reader, never the window. */
350
+ .help-article {
351
+ container: help-article / inline-size;
352
+ }
353
+
354
+ .help-article-toc[data-empty] {
355
+ visibility: hidden;
356
+ }
357
+
348
358
  .help-article-layout {
349
359
  display: grid;
350
360
  grid-template-columns: minmax(10rem, 13rem) minmax(0, 52rem);
351
361
  justify-content: center;
352
362
  align-items: start;
353
- gap: clamp(2rem, 4vw, 4rem);
363
+ gap: clamp(2rem, 4cqi, 4rem);
354
364
  }
355
365
 
356
366
  .help-article-copy {
@@ -892,7 +902,7 @@
892
902
  border: 0;
893
903
  }
894
904
 
895
- @media (max-width: 72rem) {
905
+ @container help-article (max-width: 56rem) {
896
906
  .help-article-layout {
897
907
  grid-template-columns: 3.25rem minmax(0, 52rem);
898
908
  gap: 2rem;
@@ -919,7 +929,7 @@
919
929
  }
920
930
  }
921
931
 
922
- @media (max-width: 48rem) {
932
+ @container help-article (max-width: 40rem) {
923
933
  .help-article-layout {
924
934
  grid-template-columns: minmax(0, 1fr);
925
935
  gap: 1.5rem;
@@ -303,6 +303,10 @@ solid-island {
303
303
  so short and long pages keep the same width. Full-page surfaces own their
304
304
  inner scrolling. */
305
305
  html:has(.cloud-app-canvas:not([data-layout-full-page])) { scrollbar-gutter: stable; }
306
+ /* A page that scrolls its content in a ScrollArea grows with it here, so
307
+ the area does not scroll and reserves no second gutter beside the
308
+ document's. From md, `main` bounds its height and it scrolls. */
309
+ .cloud-app-canvas:not([data-layout-full-page]) .layout-content-main > .k2b-scroll-area { scrollbar-gutter: auto; }
306
310
  .cloud-app-canvas > .layout-shell-content { gap: 0; }
307
311
  .cloud-app-canvas .layout-header {
308
312
  border-radius: 0; border: 0; border-bottom: 1px solid var(--ui-border);
@@ -316,26 +320,60 @@ solid-island {
316
320
 
317
321
  }
318
322
 
323
+ /* Card: the outermost workspace is the app card, so it gets the card's radius
324
+ and frame also when it is flat. Its sidebar and content round their corners
325
+ with that radius and clip what they show. A workspace that is `main`'s own
326
+ child fills `main` and scrolls inside itself, so `main` neither scrolls nor
327
+ clips: it keeps no scrollbar gutter beside the card, the card keeps the
328
+ shell spacing on every side, and its frame shadow shows in that spacing. A
329
+ workspace nested in other page content leaves `main` the page scroller. For
330
+ signed-in people, the flush range below removes the frame again; it comes
331
+ later at equal specificity. */
332
+ @media (min-width: 768px) {
333
+ .cloud-app-canvas .layout-content-main:has(> .k2b-app-workspace, > :is(solid-island, solid-client) > .k2b-app-workspace) {
334
+ overflow: visible;
335
+ scrollbar-gutter: auto;
336
+ }
337
+ .cloud-app-canvas .layout-content-main .k2b-app-workspace[data-flat="true"]:not(.k2b-app-workspace *) {
338
+ --k2b-workspace-radius: var(--k2b-radius-dialog);
339
+ box-shadow: var(--k2b-shadow-frame);
340
+ }
341
+ .dark .cloud-app-canvas .layout-content-main .k2b-app-workspace[data-flat="true"]:not(.k2b-app-workspace *) {
342
+ border-color: var(--k2b-border);
343
+ }
344
+ }
345
+
319
346
  /* Flush: the app area is one opaque surface from the rail to the window's
320
347
  edges, without padding, radius, shadow, or border. The rail keeps the app
321
348
  canvas, so the change of surface alone separates the two; no line does. The
322
349
  outermost workspace and the notices above it lose their frame, so nothing
323
- draws a box inside the app area. */
350
+ draws a box inside the app area.
351
+ The rail and the app area reach the window's edges also where the installed
352
+ app reports safe areas, such as the status bar and the home indicator. Their
353
+ own surfaces fill the insets, and their content stays inside them. */
324
354
  @media (min-width: 768px) and (max-width: 1535px) {
325
355
  .cloud-app-canvas[data-layout-authenticated] {
326
- padding: env(safe-area-inset-top, 0px) env(safe-area-inset-right, 0px) env(safe-area-inset-bottom, 0px) env(safe-area-inset-left, 0px);
356
+ padding: 0;
327
357
  }
328
358
  .cloud-app-canvas[data-layout-authenticated] > .layout-rail {
329
- width: 3rem;
359
+ width: calc(3rem + env(safe-area-inset-left, 0px));
330
360
  margin-inline-start: 0;
331
- padding-bottom: var(--ui-space-shell);
361
+ padding: env(safe-area-inset-top, 0px) 0 calc(var(--ui-space-shell) + env(safe-area-inset-bottom, 0px))
362
+ env(safe-area-inset-left, 0px);
332
363
  }
333
364
  .cloud-app-canvas[data-layout-authenticated] > .layout-shell-content {
334
365
  gap: 0;
366
+ padding: env(safe-area-inset-top, 0px) env(safe-area-inset-right, 0px) 0 0;
335
367
  background: var(--k2b-surface);
336
368
  }
369
+ /* A regular page scrolls `main`, so its end clears the home indicator. A
370
+ workspace reaches the bottom edge and clears it inside its own surfaces. */
371
+ .cloud-app-canvas[data-layout-authenticated] .layout-content-main:not(:has(.k2b-app-workspace)) {
372
+ padding-bottom: env(safe-area-inset-bottom, 0px);
373
+ }
337
374
  .cloud-app-canvas[data-layout-authenticated] .layout-content-main .k2b-app-workspace:not(.k2b-app-workspace *) {
338
375
  --k2b-workspace-radius: 0px;
376
+ --k2b-workspace-inset-bottom: env(safe-area-inset-bottom, 0px);
339
377
  border: 0;
340
378
  box-shadow: none;
341
379
  }
@@ -376,4 +414,4 @@ solid-island {
376
414
  width: 100%; white-space: normal; overflow-wrap: anywhere; font-size: .8125rem; line-height: 1.3;
377
415
  }
378
416
  .cloud-mobile-profile { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .5rem; border-top: 1px solid var(--ui-border); padding-top: 1rem; }
379
- .cloud-mobile-profile :is(a, button) { min-height: 2.75rem; white-space: normal; text-align: start; justify-content: flex-start; }
417
+ .cloud-mobile-profile > :is(a, button) { min-height: 2.75rem; white-space: normal; text-align: start; justify-content: flex-start; }
@@ -38,10 +38,6 @@ button[disabled],
38
38
  @apply text-white;
39
39
  }
40
40
 
41
- input[type="checkbox"]:hover {
42
- transform: var(--ui-check-hover-transform);
43
- }
44
-
45
41
  input[type="checkbox"]:focus {
46
42
  @apply outline-none;
47
43
  }
@@ -53,8 +49,4 @@ button[disabled],
53
49
  input[type="checkbox"]:disabled {
54
50
  @apply opacity-50 cursor-not-allowed;
55
51
  }
56
-
57
- input[type="checkbox"]:disabled:hover {
58
- transform: none;
59
- }
60
52
  }
@@ -38,7 +38,6 @@
38
38
  --ui-check-surface: var(--ui-field);
39
39
  --ui-check-border: var(--ui-border-strong);
40
40
  --ui-check-shadow: none;
41
- --ui-check-hover-transform: none;
42
41
  --ui-data-header: var(--ui-surface-subtle);
43
42
  --ui-data-divider: var(--ui-border);
44
43
  --ui-data-row-divider: color-mix(in srgb, var(--ui-border) 72%, transparent);
@@ -165,7 +164,6 @@
165
164
  --ui-check-surface: var(--ui-field);
166
165
  --ui-check-border: var(--ui-border-strong);
167
166
  --ui-check-shadow: none;
168
- --ui-check-hover-transform: none;
169
167
  --ui-data-row-hover: rgb(255 255 255 / 0.045);
170
168
  --ui-data-column-hover: rgb(255 255 255 / 0.018);
171
169
  --ui-dropdown-menu-surface: var(--ui-surface-raised);
@@ -932,6 +932,16 @@
932
932
  }
933
933
  }
934
934
 
935
+ /* Reserves the width of the longest appearance label, so cycling never resizes the button. */
936
+ .cloud-theme-cycle__label { display: inline-grid; justify-items: start; }
937
+ .cloud-theme-cycle__label::before { content: attr(data-reserve-a); }
938
+ .cloud-theme-cycle__label::after { content: attr(data-reserve-b); }
939
+ .cloud-theme-cycle__label::before, .cloud-theme-cycle__label::after { height: 0; overflow: hidden; visibility: hidden; }
940
+
941
+ @media (width < 48rem) {
942
+ .cloud-theme-cycle__label[data-compact] { display: none; }
943
+ }
944
+
935
945
  /* MinimalLayout stacks the page content and its footer in a column at least
936
946
  one viewport tall, so the footer ends a short page and follows a long one.
937
947
  Content that should fill the remaining height grows with flex-1. */
@@ -292,7 +292,7 @@ const compileSteps = (
292
292
  if (hasOwn(step, "switch")) {
293
293
  validateExactKeys(step, ["switch", "cases", "default"], stepPath, context);
294
294
  if (!Array.isArray(step.cases) || step.cases.length === 0) {
295
- addDiagnostic(context, "switch.cases", "switch requires at least one case", [...stepPath, "cases"]);
295
+ addDiagnostic(context, "switch.cases", "switch cases must be a non-empty list of { when, do } entries", [...stepPath, "cases"]);
296
296
  return;
297
297
  }
298
298
  const cases: Array<{ when: WorkflowJsonValue; steps: WorkflowIrStep[] }> = [];
@@ -1,21 +0,0 @@
1
- import { installAppWorkspaceController, serializeAppWorkspaceLayoutState } from "@k2b/ui";
2
- import { onCleanup, onMount } from "solid-js";
3
- import { appWorkspaceCookieName, readAppWorkspaceLayoutCookie } from "../_internal/app-workspace-state";
4
-
5
- const writeLayoutCookie = (appId: string | null | undefined, state: Parameters<typeof serializeAppWorkspaceLayoutState>[0]) => {
6
- if (!appId) return;
7
- const secure = window.location.protocol === "https:" ? "; Secure" : "";
8
- document.cookie = `${appWorkspaceCookieName(appId)}=${serializeAppWorkspaceLayoutState(state)}; Path=/; Max-Age=31536000; SameSite=Lax${secure}`;
9
- };
10
-
11
- export default function AppWorkspaceController(props: { appId?: string | null }) {
12
- let dispose = () => {};
13
- onMount(() => {
14
- dispose = installAppWorkspaceController({
15
- readState: () => readAppWorkspaceLayoutCookie(document.cookie, props.appId),
16
- writeState: (state) => writeLayoutCookie(props.appId, state),
17
- });
18
- });
19
- onCleanup(() => dispose());
20
- return null;
21
- }
@@ -1,12 +0,0 @@
1
- import { onMount } from "solid-js";
2
- import { browserNotificationClient } from "../browser/notifications";
3
-
4
- export default function BrowserPushRegistration() {
5
- onMount(() => {
6
- void browserNotificationClient.refreshExisting().catch((error) => {
7
- console.warn("[notifications] Failed to refresh the browser endpoint", error);
8
- });
9
- });
10
-
11
- return null;
12
- }
@@ -1,117 +0,0 @@
1
- import { Button, dialogCore, IconButton, MarkdownView, NoticeCard, PanelDialog, panelDialogOptions, useLocale } from "@k2b/ui";
2
- import { createSignal, For, onMount, Show } from "solid-js";
3
- import {
4
- ANNOUNCEMENTS_COOKIE,
5
- ANNOUNCEMENTS_COOKIE_MAX_AGE_SECONDS,
6
- type AnnouncementCookieState,
7
- mergeAnnouncementCookieState,
8
- serializeAnnouncementCookieState,
9
- } from "../contracts/announcement-cookie";
10
- import type { AnnouncementDisplayEntry } from "../contracts/announcements";
11
- import { platformMessages } from "./platform-messages";
12
-
13
- type Props = {
14
- banners: AnnouncementDisplayEntry[];
15
- announcements: AnnouncementDisplayEntry[];
16
- latestAnnouncementVersion: number;
17
- cookieState: AnnouncementCookieState;
18
- };
19
-
20
- const writeCookieState = (state: AnnouncementCookieState) => {
21
- document.cookie = `${ANNOUNCEMENTS_COOKIE}=${serializeAnnouncementCookieState(state)}; Path=/; Max-Age=${ANNOUNCEMENTS_COOKIE_MAX_AGE_SECONDS}; SameSite=Lax`;
22
- };
23
-
24
- export default function GlobalAnnouncements(props: Props) {
25
- const locale = useLocale();
26
- const t = () => platformMessages.resolve([locale()]).t;
27
- const [cookieState, setCookieState] = createSignal(props.cookieState);
28
- const [banners, setBanners] = createSignal(props.banners);
29
-
30
- const dismissBanner = (version: number) => {
31
- const next = mergeAnnouncementCookieState(cookieState(), { dismissedBannerVersions: [version] });
32
- setCookieState(next);
33
- writeCookieState(next);
34
- setBanners((items) => items.filter((item) => item.version !== version));
35
- };
36
-
37
- const closeAnnouncements = () => {
38
- const next = mergeAnnouncementCookieState(cookieState(), {
39
- seenAnnouncementVersion: props.latestAnnouncementVersion,
40
- });
41
- setCookieState(next);
42
- writeCookieState(next);
43
- };
44
-
45
- onMount(() => {
46
- if (props.announcements.length === 0) return;
47
-
48
- void dialogCore
49
- .open<void>(
50
- (close) => (
51
- <PanelDialog>
52
- <PanelDialog.Header
53
- title={t().announcements}
54
- subtitle={t().latestPlatformUpdates}
55
- icon="ti ti-speakerphone text-blue-500"
56
- close={() => close()}
57
- />
58
- <PanelDialog.Body>
59
- <For each={props.announcements}>
60
- {(entry) => (
61
- <article class="panel-dialog-section rounded-[var(--ui-radius-surface)] p-4">
62
- <div class="mb-3 flex items-start justify-between gap-3">
63
- <div class="min-w-0">
64
- <h2 class="text-base font-semibold text-primary">{entry.title}</h2>
65
- <p class="mt-0.5 text-xs text-dimmed">
66
- {new Date(entry.publishedAt).toLocaleDateString(locale(), {
67
- year: "numeric",
68
- month: "short",
69
- day: "numeric",
70
- })}
71
- </p>
72
- </div>
73
- <span class="rounded bg-zinc-100 px-1.5 py-0.5 text-[10px] font-medium text-dimmed dark:bg-zinc-800">
74
- v{entry.version}
75
- </span>
76
- </div>
77
- <MarkdownView trustedHtml={entry.bodyHtml} />
78
- </article>
79
- )}
80
- </For>
81
- </PanelDialog.Body>
82
- <PanelDialog.Footer>
83
- <span />
84
- <Button size="sm" onClick={() => close()}>
85
- {t().gotIt}
86
- </Button>
87
- </PanelDialog.Footer>
88
- </PanelDialog>
89
- ),
90
- panelDialogOptions,
91
- )
92
- .then(closeAnnouncements);
93
- });
94
-
95
- return (
96
- <Show when={banners().length > 0}>
97
- <div class="flex shrink-0 flex-col gap-1">
98
- <For each={banners()}>
99
- {(banner) => (
100
- <div class="cloud-announcement">
101
- <NoticeCard tone={banner.tone} title={banner.title}>
102
- <MarkdownView
103
- trustedHtml={banner.bodyHtml}
104
- headingScale="compact"
105
- class="-mx-2 max-h-36 overflow-y-auto overscroll-contain pl-2 pr-3 [&_p]:my-0"
106
- />
107
- </NoticeCard>
108
- <IconButton size="sm" label={t().dismissBanner} onClick={() => dismissBanner(banner.version)}>
109
- <i class="ti ti-x" aria-hidden="true" />
110
- </IconButton>
111
- </div>
112
- )}
113
- </For>
114
- </div>
115
- </Show>
116
- );
117
- }
@@ -1,104 +0,0 @@
1
- import { prompts, ScrollArea, useLocale } from "@k2b/ui";
2
- import { createMemo, For, Show } from "solid-js";
3
- import { platformMessages } from "./platform-messages";
4
-
5
- export type GlobalSearchHelpApp = {
6
- appId: string;
7
- appName: string;
8
- appIcon: string;
9
- tags: string[];
10
- help?: string;
11
- tagHelp?: Array<{ tag: string; help: string }>;
12
- };
13
-
14
- type GlobalSearchHelpDialogProps = { apps: GlobalSearchHelpApp[] };
15
-
16
- export default function GlobalSearchHelpDialog(props: GlobalSearchHelpDialogProps) {
17
- const locale = useLocale();
18
- const t = () => platformMessages.resolve([locale()]).t;
19
- const apps = createMemo(() =>
20
- props.apps
21
- .filter((app) => app.tags.length > 0)
22
- .map((app) => ({
23
- ...app,
24
- help: app.help?.trim() || undefined,
25
- tags: [...new Set(app.tags.map((tag) => tag.toLowerCase()))].sort(),
26
- tagHelp: [...new Map((app.tagHelp ?? []).map((entry) => [entry.tag.trim().toLowerCase(), entry.help.trim()])).entries()]
27
- .filter(([tag, help]) => tag.length > 0 && help.length > 0)
28
- .map(([tag, help]) => ({ tag, help })),
29
- }))
30
- .sort((a, b) => a.appName.localeCompare(b.appName)),
31
- );
32
-
33
- return (
34
- <div class="flex max-h-[min(42rem,var(--ui-dialog-available-height))] min-h-0 flex-col gap-4 text-zinc-900 dark:text-zinc-100">
35
- <div class="flex items-start justify-between gap-3">
36
- <div>
37
- <p class="text-sm text-dimmed">{t().searchTagsDescription({ tag: "#tag" })}</p>
38
- </div>
39
- </div>
40
-
41
- <ScrollArea class="min-h-0 flex-1 pr-1">
42
- <div class="flex flex-col gap-2">
43
- <For each={apps()}>
44
- {(app) => (
45
- <section class="rounded-lg ring-1 ring-inset ring-zinc-200 dark:ring-zinc-800 p-3 bg-zinc-50/50 dark:bg-zinc-900/35">
46
- <header class="flex items-center gap-2 text-sm">
47
- <i class={app.appIcon} />
48
- <span>{app.appName}</span>
49
- </header>
50
- <Show when={app.help}>
51
- <p class="mt-1 text-xs text-dimmed">{app.help}</p>
52
- </Show>
53
- <Show
54
- when={(app.tagHelp?.length ?? 0) > 0}
55
- fallback={
56
- <p class="mt-1 text-xs text-dimmed">
57
- {t().tags}:{" "}
58
- <For each={app.tags}>
59
- {(tag, index) => (
60
- <>
61
- <code>#{tag}</code>
62
- <Show when={index() < app.tags.length - 1}>, </Show>
63
- </>
64
- )}
65
- </For>
66
- </p>
67
- }
68
- >
69
- <div class="mt-2 space-y-1 text-xs">
70
- <For each={app.tagHelp}>
71
- {(entry) => (
72
- <div class="grid grid-cols-[6rem_minmax(0,1fr)] gap-2">
73
- <code>#{entry.tag}</code>
74
- <span class="text-dimmed">{entry.help}</span>
75
- </div>
76
- )}
77
- </For>
78
- </div>
79
- </Show>
80
- </section>
81
- )}
82
- </For>
83
- <Show when={apps().length === 0}>
84
- <div class="rounded-lg ring-1 ring-inset ring-zinc-200 dark:ring-zinc-800 p-3 text-xs text-dimmed bg-zinc-50/50 dark:bg-zinc-900/35">
85
- {t().noAppSearchTags}
86
- </div>
87
- </Show>
88
- </div>
89
- </ScrollArea>
90
- </div>
91
- );
92
- }
93
-
94
- export const openGlobalSearchHelpDialog = (
95
- apps: GlobalSearchHelpApp[],
96
- locale = typeof document === "undefined" ? "en" : document.documentElement.lang || "en",
97
- ) => {
98
- const t = platformMessages.resolve([locale]).t;
99
- void prompts.dialog<void>(() => <GlobalSearchHelpDialog apps={apps} />, {
100
- title: t.searchTags,
101
- icon: "ti ti-help-circle",
102
- size: "large",
103
- });
104
- };