@k2b/cloud 0.33.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 (65) hide show
  1. package/package.json +3 -3
  2. package/src/_internal/app-presentation.ts +40 -11
  3. package/src/_internal/define-app.ts +1 -0
  4. package/src/_internal/registry.ts +10 -1
  5. package/src/_internal/widget-declarations.ts +16 -3
  6. package/src/_internal/widget-settings.ts +93 -0
  7. package/src/ai/capabilities.ts +8 -5
  8. package/src/ai/resource-refs.ts +35 -3
  9. package/src/ai/system-prompt.ts +3 -2
  10. package/src/api/index.ts +2 -0
  11. package/src/api/mcp.ts +48 -17
  12. package/src/api/pwa.ts +7 -2
  13. package/src/api/resource-links.ts +43 -0
  14. package/src/api/widgets.ts +163 -82
  15. package/src/browser/CloudResourceSearch.tsx +6 -6
  16. package/src/browser/command-shortcuts.ts +2 -11
  17. package/src/browser/resource-picker.tsx +1 -0
  18. package/src/browser/resource-search.ts +7 -2
  19. package/src/browser/widgets.ts +23 -5
  20. package/src/capabilities/testing.ts +13 -1
  21. package/src/cli/capabilities.ts +61 -17
  22. package/src/cli/index.ts +2 -0
  23. package/src/cli/resource-links.ts +13 -0
  24. package/src/contracts/app.ts +7 -1
  25. package/src/contracts/capabilities.ts +25 -5
  26. package/src/contracts/registry.ts +3 -1
  27. package/src/contracts/resource-href.ts +7 -0
  28. package/src/contracts/widgets.ts +80 -6
  29. package/src/desktop/index.ts +3 -2
  30. package/src/desktop/solid.tsx +7 -5
  31. package/src/server/widget-request.ts +34 -5
  32. package/src/services/app-approval.ts +4 -2
  33. package/src/services/approvals/index.ts +40 -11
  34. package/src/services/approvals/list.ts +1 -0
  35. package/src/services/approvals/migrate.ts +10 -0
  36. package/src/services/pdf/markdown.ts +11 -7
  37. package/src/services/pwa-devices.ts +68 -15
  38. package/src/shared/app-presentation.ts +49 -1
  39. package/src/shared/index.ts +1 -0
  40. package/src/shared/markdown/callout-sanitizer.ts +37 -0
  41. package/src/shared/markdown/index.ts +8 -2
  42. package/src/ssr/Layout.tsx +3 -15
  43. package/src/ssr/LayoutHelp.browser-harness.tsx +43 -0
  44. package/src/ssr/LayoutHelp.tsx +319 -183
  45. package/src/ssr/LayoutHelpTrigger.tsx +1 -3
  46. package/src/ssr/LayoutPreferences.tsx +15 -5
  47. package/src/ssr/MinimalLayoutPreferences.island.tsx +3 -9
  48. package/src/ssr/MobileProfileActions.tsx +3 -8
  49. package/src/ssr/ProfilePreferences.tsx +8 -10
  50. package/src/ssr/ShellRuntime.island.tsx +1 -5
  51. package/src/ssr/ThemeCycleButton.tsx +46 -0
  52. package/src/ssr/app-appearance.ts +6 -1
  53. package/src/ssr/layout-help-url.ts +39 -0
  54. package/src/ssr/platform-messages.ts +0 -8
  55. package/src/ssr/preference-controller.ts +53 -19
  56. package/src/ssr/profile-actions.ts +12 -16
  57. package/src/ssr/profile-preferences-messages.ts +4 -2
  58. package/src/ssr/rail-navigation.ts +3 -0
  59. package/src/ssr/shell-data.tsx +2 -2
  60. package/src/styles/effects.css +13 -3
  61. package/src/styles/global.css +27 -1
  62. package/src/styles/input.css +0 -8
  63. package/src/styles/tokens.css +0 -2
  64. package/src/styles/utilities-navigation.css +10 -13
  65. package/src/ssr/GlobalSearchHelpDialog.tsx +0 -104
@@ -2,7 +2,6 @@ import { IconButton, Tooltip, useLocale } from "@k2b/ui";
2
2
  import { importOnDemand } from "../browser/reload";
3
3
  import { createBusyAction } from "./busy-action";
4
4
  import { platformMessages } from "./platform-messages";
5
- import { useShellData } from "./shell-data";
6
5
 
7
6
  type LayoutHelpTriggerProps = {
8
7
  variant: "header" | "rail";
@@ -13,12 +12,11 @@ type LayoutHelpTriggerProps = {
13
12
  export default function LayoutHelpTrigger(props: LayoutHelpTriggerProps) {
14
13
  const locale = useLocale();
15
14
  const t = () => platformMessages.resolve([locale()]).t;
16
- const data = useShellData();
17
15
  const action = createBusyAction();
18
16
  const openHelp = () =>
19
17
  action.run(
20
18
  () => importOnDemand(() => import("./LayoutHelp")),
21
- (help) => help.openLayoutHelpDialog(data?.searchHelpApps ?? [], data?.accent),
19
+ (help) => help.openLayoutHelpDialog(),
22
20
  );
23
21
 
24
22
  if (props.variant === "rail")
@@ -12,10 +12,20 @@ export default function LayoutPreferences(props: LayoutPreferencesProps) {
12
12
  const preferences = createPreferenceController(props.initialTheme, locale);
13
13
 
14
14
  return (
15
- <Dropdown.Root items={preferences.items()} label={preferences.messages().preferencesMenuLabel} position={props.position} width="14rem">
16
- <Dropdown.Trigger iconOnly label={preferences.messages().preferencesMenuLabel} size="sm" variant="secondary">
17
- <i class="ti ti-adjustments-horizontal" aria-hidden="true" />
18
- </Dropdown.Trigger>
19
- </Dropdown.Root>
15
+ <>
16
+ <Dropdown.Root
17
+ items={preferences.items()}
18
+ label={preferences.messages().preferencesMenuLabel}
19
+ position={props.position}
20
+ width="14rem"
21
+ >
22
+ <Dropdown.Trigger iconOnly label={preferences.messages().preferencesMenuLabel} size="sm" variant="secondary">
23
+ <i class="ti ti-adjustments-horizontal" aria-hidden="true" />
24
+ </Dropdown.Trigger>
25
+ </Dropdown.Root>
26
+ <span class="sr-only" aria-live="polite">
27
+ {preferences.themeAnnouncement()}
28
+ </span>
29
+ </>
20
30
  );
21
31
  }
@@ -3,13 +3,13 @@ import { canonicalLocale } from "../shared/locale";
3
3
  import { type ProfilePreferenceLocale, profilePreferenceLocale, setLocalePreference } from "../shared/locale-preference";
4
4
  import type { CloudThemePreference } from "../shared/theme";
5
5
  import { createPreferenceController } from "./preference-controller";
6
+ import { ThemeCycleButton } from "./ThemeCycleButton";
6
7
 
7
- /** Both menus name the current choice; compact triggers keep accessible names below md. */
8
+ /** Language opens a menu; appearance cycles in place and keeps its accessible name below md. */
8
9
  export default function MinimalLayoutPreferences(props: { initialTheme: CloudThemePreference }) {
9
10
  const locale = useLocale();
10
11
  const preferences = createPreferenceController(props.initialTheme, locale);
11
12
  const t = preferences.messages;
12
- const currentTheme = () => preferences.themeOption(preferences.theme());
13
13
  const language = () => profilePreferenceLocale(locale());
14
14
  const languageName = () => (language() === "de" ? t().switchToGerman : t().switchToEnglish);
15
15
  // Keep a regional locale such as en-GB when its language is chosen again,
@@ -37,13 +37,7 @@ export default function MinimalLayoutPreferences(props: { initialTheme: CloudThe
37
37
  <i class="ti ti-chevron-down hidden md:inline" aria-hidden="true" />
38
38
  </Dropdown.Trigger>
39
39
  </Dropdown.Root>
40
- <Dropdown.Root label={t().appearance} position="top-right" width="12rem" items={preferences.appearance().items}>
41
- <Dropdown.Trigger label={`${t().appearance}: ${currentTheme().label}`} size="sm" tooltip={false} variant="ghost">
42
- <i class={currentTheme().icon} aria-hidden="true" />
43
- <span class="hidden md:inline">{currentTheme().label}</span>
44
- <i class="ti ti-chevron-down hidden md:inline" aria-hidden="true" />
45
- </Dropdown.Trigger>
46
- </Dropdown.Root>
40
+ <ThemeCycleButton preferences={preferences} size="sm" variant="ghost" compact />
47
41
  </div>
48
42
  );
49
43
  }
@@ -1,7 +1,8 @@
1
1
  import { documentNavigate } from "@k2b/ssr/nav";
2
- import { Button, ButtonLink, SegmentedControl } from "@k2b/ui";
2
+ import { Button, ButtonLink } from "@k2b/ui";
3
3
  import type { CloudThemePreference } from "../shared/theme";
4
4
  import { createProfileActions } from "./profile-actions";
5
+ import { ThemeCycleButton } from "./ThemeCycleButton";
5
6
 
6
7
  export function MobileProfileActions(props: {
7
8
  profile: { name: string; theme: CloudThemePreference; appHref?: string };
@@ -17,13 +18,7 @@ export function MobileProfileActions(props: {
17
18
  };
18
19
  return (
19
20
  <section class="cloud-mobile-profile" aria-label={preferences.messages().menuLabel}>
20
- <SegmentedControl
21
- class="cloud-mobile-profile__appearance"
22
- ariaLabel={preferences.messages().appearance}
23
- options={preferences.themeOptions()}
24
- value={preferences.theme}
25
- onValueChange={preferences.setTheme}
26
- />
21
+ <ThemeCycleButton preferences={preferences} variant="ghost" />
27
22
  <ButtonLink href="/me" variant="ghost" onClick={navigate("/me")}>
28
23
  <i class="ti ti-user-circle" aria-hidden="true" />
29
24
  {preferences.messages().profileSettings}
@@ -1,4 +1,4 @@
1
- import { Avatar, Dropdown, SegmentedControl } from "@k2b/ui";
1
+ import { Avatar, Dropdown } from "@k2b/ui";
2
2
  import type { CloudThemePreference } from "../shared/theme";
3
3
  import { createProfileActions } from "./profile-actions";
4
4
 
@@ -26,15 +26,10 @@ export default function ProfilePreferences(props: ProfilePreferencesProps) {
26
26
  {avatar()}
27
27
  </button>
28
28
  <div class="layout-profile-preferences__panel dropdown-menu-surface">
29
- <div class="layout-profile-preferences__appearance">
30
- <SegmentedControl
31
- size="sm"
32
- ariaLabel={preferences.messages().appearance}
33
- options={preferences.themeOptions().map(({ value, label }) => ({ value, label }))}
34
- value={preferences.theme}
35
- onValueChange={preferences.setTheme}
36
- />
37
- </div>
29
+ <button type="button" class="menu-item" aria-label={preferences.themeCycleLabel()} onClick={preferences.cycleTheme}>
30
+ <i class={preferences.themeOption(preferences.theme()).icon} aria-hidden="true" />
31
+ <span>{preferences.themeOption(preferences.theme()).label}</span>
32
+ </button>
38
33
  <button type="button" class="menu-item" onClick={preferences.toggleLanguage}>
39
34
  <i class="ti ti-language" aria-hidden="true" />
40
35
  <span>{preferences.languageLabel()}</span>
@@ -71,6 +66,9 @@ export default function ProfilePreferences(props: ProfilePreferencesProps) {
71
66
  {avatar()}
72
67
  </Dropdown.Trigger>
73
68
  </Dropdown.Root>
69
+ <span class="sr-only" aria-live="polite">
70
+ {preferences.themeAnnouncement()}
71
+ </span>
74
72
  </div>
75
73
  );
76
74
  }
@@ -128,11 +128,7 @@ export default function ShellRuntime(props: ShellRuntimeProps) {
128
128
  icon: "ti ti-help-circle",
129
129
  shortcut: "shift+/",
130
130
  // Returning the load keeps the Command busy until the dialog is open; a second press opens no second dialog.
131
- action: () =>
132
- importOnDemand(() => import("./LayoutHelp")).then((help) => {
133
- const data = readShellData();
134
- help?.openLayoutHelpDialog(data?.searchHelpApps ?? [], data?.accent);
135
- }),
131
+ action: () => importOnDemand(() => import("./LayoutHelp")).then((help) => help?.openLayoutHelpDialog()),
136
132
  }),
137
133
  ),
138
134
  );
@@ -0,0 +1,46 @@
1
+ import { Button, type ButtonSize, type ButtonVariant } from "@k2b/ui";
2
+ import { type createPreferenceController, THEME_CYCLE } from "./preference-controller";
3
+
4
+ type Props = {
5
+ preferences: ReturnType<typeof createPreferenceController>;
6
+ variant: ButtonVariant;
7
+ size?: ButtonSize;
8
+ /** Shows only the icon below `md`; the accessible name still states the choice. */
9
+ compact?: boolean;
10
+ };
11
+
12
+ /**
13
+ * One button for the appearance: it shows the current choice and each click switches to the next. The label reserves
14
+ * the width of the two other choices, so the button and its neighbors stay in place.
15
+ */
16
+ export function ThemeCycleButton(props: Props) {
17
+ const current = () => props.preferences.themeOption(props.preferences.theme());
18
+ const otherLabels = () =>
19
+ THEME_CYCLE.filter((value) => value !== props.preferences.theme()).map((value) => props.preferences.themeOption(value).label);
20
+
21
+ return (
22
+ <>
23
+ <Button
24
+ type="button"
25
+ class="cloud-theme-cycle"
26
+ variant={props.variant}
27
+ size={props.size}
28
+ aria-label={props.preferences.themeCycleLabel()}
29
+ onClick={props.preferences.cycleTheme}
30
+ >
31
+ <i class={current().icon} aria-hidden="true" />
32
+ <span
33
+ class="cloud-theme-cycle__label"
34
+ data-compact={props.compact ? "true" : undefined}
35
+ data-reserve-a={otherLabels()[0]}
36
+ data-reserve-b={otherLabels()[1]}
37
+ >
38
+ <span>{current().label}</span>
39
+ </span>
40
+ </Button>
41
+ <span class="sr-only" aria-live="polite">
42
+ {props.preferences.themeAnnouncement()}
43
+ </span>
44
+ </>
45
+ );
46
+ }
@@ -1,5 +1,6 @@
1
1
  import type { AppAppearance } from "../contracts/app";
2
2
  import { resolveRuntimeRoute } from "./app-navigation";
3
+ import { helpPageAppId } from "./layout-help-url";
3
4
  import type { RuntimeContext } from "./runtime";
4
5
 
5
6
  const isHexColor = (value: string | undefined): value is `#${string}` => /^#[0-9a-f]{6}$/i.test(value ?? "");
@@ -20,4 +21,8 @@ export const appAppearanceStyle = (appearance: AppAppearance | undefined): strin
20
21
  export const appAccentStyle = (accent: string | undefined): string | undefined =>
21
22
  isHexColor(accent) ? `--app-accent:${accent}` : undefined;
22
23
 
23
- export const resolveCurrentApp = (apps: RuntimeContext["apps"], pathname: string) => resolveRuntimeRoute(apps, pathname)?.app;
24
+ /** Core's full-page Help of an app belongs to that app. */
25
+ export const resolveCurrentApp = (apps: RuntimeContext["apps"], pathname: string) => {
26
+ const helpAppId = helpPageAppId(pathname);
27
+ return apps.find((app) => app.id === helpAppId) ?? resolveRuntimeRoute(apps, pathname)?.app;
28
+ };
@@ -3,3 +3,42 @@ export const layoutHelpTopicHref = (pageBase: string, topicId: string | null) =>
3
3
  const base = pageBase.endsWith("/") ? pageBase.slice(0, -1) : pageBase;
4
4
  return topicId ? `${base}/${encodeURIComponent(topicId)}` : base;
5
5
  };
6
+
7
+ /** The app named by Core's exact full-page Help route. */
8
+ export const helpPageAppId = (pathname: string): string | null => {
9
+ const match = /^\/help\/apps\/([^/]+)(?:\/[^/]+)?\/?$/.exec(pathname);
10
+ if (!match?.[1]) return null;
11
+ try {
12
+ return decodeURIComponent(match[1]);
13
+ } catch {
14
+ return null;
15
+ }
16
+ };
17
+
18
+ type HelpLinkTarget = { kind: "section"; hash: string } | { kind: "topic"; topicId: string | null; hash: string };
19
+
20
+ /** Same-app Help links that the reader can open in place. */
21
+ export const helpLinkTarget = (href: string, current: string, help: { appId?: string; pageBase?: string }): HelpLinkTarget | null => {
22
+ try {
23
+ if (href.startsWith("#")) {
24
+ const hash = decodeURIComponent(href.slice(1));
25
+ return hash ? { kind: "section", hash } : null;
26
+ }
27
+ const source = new URL(current);
28
+ const target = new URL(href, source);
29
+ if (target.origin !== source.origin || target.search) return null;
30
+ const bases = [help.pageBase?.replace(/\/$/, ""), help.appId ? `/help/apps/${encodeURIComponent(help.appId)}` : undefined];
31
+ const hash = decodeURIComponent(target.hash.slice(1));
32
+ for (const base of bases) {
33
+ if (!base) continue;
34
+ const path = target.pathname.replace(/\/$/, "");
35
+ if (path === base) return { kind: "topic", topicId: null, hash };
36
+ if (!path.startsWith(`${base}/`)) continue;
37
+ const segment = path.slice(base.length + 1);
38
+ if (segment && !segment.includes("/")) return { kind: "topic", topicId: decodeURIComponent(segment), hash };
39
+ }
40
+ return null;
41
+ } catch {
42
+ return null;
43
+ }
44
+ };
@@ -56,10 +56,6 @@ export const platformMessages = i18n.define({
56
56
  latestPlatformUpdates: "Latest platform updates",
57
57
  gotIt: "Got it",
58
58
  dismissBanner: "Dismiss banner",
59
- searchTags: "Search tags",
60
- searchTagsDescription: ({ tag }: { tag: string }) => `Use ${tag} to narrow your search. You can combine text and multiple tags.`,
61
- tags: "Tags",
62
- noAppSearchTags: "No app-specific search tags are available.",
63
59
  openGlobalSearch: "Open global search",
64
60
  globalSearchDescription: "Search across apps, pages, files, and items.",
65
61
  searchShortcut: "Search (⌘/Ctrl+K)",
@@ -127,10 +123,6 @@ export const platformMessages = i18n.define({
127
123
  latestPlatformUpdates: "Neuigkeiten zur Plattform",
128
124
  gotIt: "Verstanden",
129
125
  dismissBanner: "Banner ausblenden",
130
- searchTags: "Such-Tags",
131
- searchTagsDescription: ({ tag }) => `Verwende ${tag} für eine gezieltere Suche. Text und mehrere Tags lassen sich kombinieren.`,
132
- tags: "Tags",
133
- noAppSearchTags: "Keine app-spezifischen Such-Tags verfügbar.",
134
126
  openGlobalSearch: "Globale Suche öffnen",
135
127
  globalSearchDescription: "Apps, Seiten, Dateien und weitere Inhalte durchsuchen.",
136
128
  searchShortcut: "Suchen (⌘/Strg+K)",
@@ -1,15 +1,19 @@
1
- import type { DropdownAction, DropdownItem, DropdownSection } from "@k2b/ui";
1
+ import type { DropdownAction, DropdownItem } from "@k2b/ui";
2
2
  import { createMemo, createSignal, onCleanup, onMount } from "solid-js";
3
3
  import { profilePreferenceLocale, setLocalePreference } from "../shared/locale-preference";
4
4
  import { type CloudThemePreference, getCurrentThemePreference, setThemePreference } from "../shared/theme";
5
5
  import { profilePreferencesMessages } from "./profile-preferences-messages";
6
6
 
7
7
  const THEME_PREFERENCE_EVENT = "cloud:theme-preference";
8
- const THEME_PREFERENCES = ["light", "dark", "system"] as const satisfies readonly CloudThemePreference[];
9
- const THEME_ICONS: Record<CloudThemePreference, string> = { light: "ti ti-sun-high", dark: "ti ti-moon", system: "ti ti-sun-moon" };
8
+ export const THEME_CYCLE = ["system", "light", "dark"] as const satisfies readonly CloudThemePreference[];
9
+ const THEME_ICONS: Record<CloudThemePreference, string> = { light: "ti ti-sun-high", dark: "ti ti-moon", system: "ti ti-device-laptop" };
10
+
11
+ export const nextThemePreference = (current: CloudThemePreference): CloudThemePreference =>
12
+ THEME_CYCLE[(THEME_CYCLE.indexOf(current) + 1) % THEME_CYCLE.length]!;
10
13
 
11
14
  export const createPreferenceController = (initialTheme: CloudThemePreference, locale: () => string) => {
12
15
  const [theme, updateTheme] = createSignal(initialTheme);
16
+ const [announcedTheme, setAnnouncedTheme] = createSignal<CloudThemePreference>();
13
17
  const messages = () => profilePreferencesMessages.resolve([locale()]).t;
14
18
  const language = () => profilePreferenceLocale(locale());
15
19
  const nextLanguage = () => (language() === "de" ? "en" : "de");
@@ -22,7 +26,10 @@ export const createPreferenceController = (initialTheme: CloudThemePreference, l
22
26
  const toggleLanguage = () => setLocalePreference(nextLanguage());
23
27
 
24
28
  onMount(() => {
25
- const syncTheme = () => updateTheme(getCurrentThemePreference());
29
+ const syncTheme = () => {
30
+ const syncedTheme = updateTheme(getCurrentThemePreference());
31
+ if (syncedTheme !== announcedTheme()) setAnnouncedTheme(undefined);
32
+ };
26
33
  syncTheme();
27
34
  window.addEventListener(THEME_PREFERENCE_EVENT, syncTheme);
28
35
  onCleanup(() => window.removeEventListener(THEME_PREFERENCE_EVENT, syncTheme));
@@ -33,21 +40,48 @@ export const createPreferenceController = (initialTheme: CloudThemePreference, l
33
40
  icon: THEME_ICONS[value],
34
41
  label: { light: messages().themeLight, dark: messages().themeDark, system: messages().themeSystem }[value],
35
42
  });
36
- const themeOptions = () => THEME_PREFERENCES.map(themeOption);
37
- const appearance = (): DropdownSection => ({
38
- sectionLabel: messages().appearance,
39
- layout: "row",
40
- items: themeOptions().map(({ value, label, icon }) => ({
41
- label,
42
- icon,
43
- choice: "radio",
44
- checked: () => theme() === value,
45
- closeOnSelect: false,
46
- action: () => setTheme(value),
47
- })),
48
- });
43
+ const cycleTheme = () => {
44
+ const next = nextThemePreference(theme());
45
+ setTheme(next);
46
+ setAnnouncedTheme(next);
47
+ };
48
+ const themeCycleLabel = () =>
49
+ messages().themeCycle({
50
+ current: themeOption(theme()).label,
51
+ next: themeOption(nextThemePreference(theme())).label,
52
+ });
53
+ const themeAnnouncement = () => {
54
+ const current = announcedTheme();
55
+ return current === undefined ? "" : messages().themeChanged({ current: themeOption(current).label });
56
+ };
57
+ const appearanceItem: DropdownAction = {
58
+ get icon() {
59
+ return themeOption(theme()).icon;
60
+ },
61
+ get label() {
62
+ return themeOption(theme()).label;
63
+ },
64
+ get ariaLabel() {
65
+ return themeCycleLabel();
66
+ },
67
+ closeOnSelect: false,
68
+ action: cycleTheme,
69
+ };
49
70
  const languageItem = (): DropdownAction => ({ action: toggleLanguage, icon: "ti ti-language", label: languageLabel() });
50
- const items = createMemo<DropdownItem[]>(() => [appearance(), { items: [languageItem()] }]);
71
+ const items = createMemo<DropdownItem[]>(() => [appearanceItem, languageItem()]);
51
72
 
52
- return { items, appearance, languageItem, themeOption, themeOptions, language, languageLabel, messages, theme, setTheme, toggleLanguage };
73
+ return {
74
+ items,
75
+ appearanceItem,
76
+ languageItem,
77
+ themeOption,
78
+ language,
79
+ languageLabel,
80
+ messages,
81
+ theme,
82
+ cycleTheme,
83
+ themeCycleLabel,
84
+ themeAnnouncement,
85
+ toggleLanguage,
86
+ };
53
87
  };
@@ -31,23 +31,19 @@ export function createProfileActions(initialTheme: CloudThemePreference, appHref
31
31
  };
32
32
 
33
33
  const items = createMemo<DropdownItem[]>(() => [
34
- preferences.appearance(),
34
+ preferences.appearanceItem,
35
+ preferences.languageItem(),
35
36
  {
36
- items: [
37
- preferences.languageItem(),
38
- {
39
- href: "/me",
40
- icon: "ti ti-user-circle",
41
- label: preferences.messages().profileSettings,
42
- },
43
- ...(appHref ? [{ href: appHref, icon: "ti ti-device-mobile", label: preferences.messages().app }] : []),
44
- {
45
- action: signOut,
46
- icon: "ti ti-logout",
47
- label: preferences.messages().signOut,
48
- disabled: signingOut(),
49
- },
50
- ],
37
+ href: "/me",
38
+ icon: "ti ti-user-circle",
39
+ label: preferences.messages().profileSettings,
40
+ },
41
+ ...(appHref ? [{ href: appHref, icon: "ti ti-device-mobile", label: preferences.messages().app }] : []),
42
+ {
43
+ action: signOut,
44
+ icon: "ti ti-logout",
45
+ label: preferences.messages().signOut,
46
+ disabled: signingOut(),
51
47
  },
52
48
  ]);
53
49
 
@@ -12,10 +12,11 @@ export const profilePreferencesMessages = i18n.define({
12
12
  app: "App",
13
13
  signOut: "Sign out",
14
14
  signOutFailed: "Sign out failed. Please try again.",
15
- appearance: "Appearance",
16
15
  themeLight: "Light",
17
16
  themeDark: "Dark",
18
17
  themeSystem: "Automatic",
18
+ themeCycle: ({ current, next }: { current: string; next: string }) => `Appearance: ${current}. Switches to ${next}.`,
19
+ themeChanged: ({ current }: { current: string }) => `Appearance: ${current}`,
19
20
  switchToGerman: "Deutsch",
20
21
  switchToEnglish: "English",
21
22
  },
@@ -28,10 +29,11 @@ export const profilePreferencesMessages = i18n.define({
28
29
  app: "App",
29
30
  signOut: "Abmelden",
30
31
  signOutFailed: "Abmelden fehlgeschlagen. Bitte versuche es erneut.",
31
- appearance: "Darstellung",
32
32
  themeLight: "Hell",
33
33
  themeDark: "Dunkel",
34
34
  themeSystem: "Automatisch",
35
+ themeCycle: ({ current, next }) => `Darstellung: ${current}. Wechselt zu ${next}.`,
36
+ themeChanged: ({ current }) => `Darstellung: ${current}`,
35
37
  switchToGerman: "Deutsch",
36
38
  switchToEnglish: "English",
37
39
  },
@@ -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;
@@ -3,7 +3,6 @@ import type { AppSearchLink } from "../contracts/app";
3
3
  import type { RailSnapshot } from "../contracts/rail-preferences";
4
4
  import type { CloudThemePreference } from "../shared/theme";
5
5
  import type { AppLaunchpadLegalLink } from "./app-launchpad";
6
- import type { GlobalSearchHelpApp } from "./GlobalSearchHelpDialog";
7
6
  import type { RailApp } from "./rail-navigation";
8
7
 
9
8
  export type ShellData = {
@@ -19,7 +18,8 @@ export type ShellData = {
19
18
  accent?: string;
20
19
  /** The search destinations of each app in `apps`, by app id in registry order; the search host expands them into rows. */
21
20
  searchLinks: Record<string, readonly AppSearchLink[]>;
22
- searchHelpApps: GlobalSearchHelpApp[];
21
+ /** The current app, for links inside its Help. */
22
+ appId?: string;
23
23
  };
24
24
 
25
25
  const ShellDataContext = createContext<() => ShellData>();
@@ -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,6 +320,29 @@ 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
@@ -388,4 +415,3 @@ solid-island {
388
415
  }
389
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; }
390
417
  .cloud-mobile-profile > :is(a, button) { min-height: 2.75rem; white-space: normal; text-align: start; justify-content: flex-start; }
391
- .cloud-mobile-profile__appearance { grid-column: 1 / -1; }
@@ -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);
@@ -883,19 +883,6 @@
883
883
  transition: opacity 120ms ease-out;
884
884
  }
885
885
 
886
- .layout-profile-preferences__appearance {
887
- padding: 0.125rem 0.125rem 0.25rem;
888
- }
889
-
890
- .layout-profile-preferences__appearance .k2b-segmented-control {
891
- width: 100%;
892
- }
893
-
894
- /* Content-sized segments: "Automatisch" needs more room than "Hell" in the narrow panel. */
895
- .layout-profile-preferences__appearance .k2b-segmented-control__option {
896
- flex: 1 1 auto;
897
- }
898
-
899
886
  .dark .layout-profile-preferences__panel {
900
887
  color: var(--color-zinc-300);
901
888
  }
@@ -945,6 +932,16 @@
945
932
  }
946
933
  }
947
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
+
948
945
  /* MinimalLayout stacks the page content and its footer in a column at least
949
946
  one viewport tall, so the footer ends a short page and follows a long one.
950
947
  Content that should fill the remaining height grows with flex-1. */