@k2b/cloud 0.18.0 → 0.20.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.
@@ -1,46 +1,70 @@
1
1
  import { LocaleProvider } from "@k2b/ui";
2
2
  import type { JSX } from "solid-js/jsx-runtime";
3
3
  import { getLocale } from "../server/locale";
4
+ import { resolveAppPresentations } from "../shared/app-presentation";
4
5
  import { readThemeFromCookieHeader } from "../shared/theme";
5
- import LayoutPreferences from "./LayoutPreferences.island";
6
6
  import type { MinimalLayoutContext } from "./layout-context";
7
+ import MinimalLayoutPreferences from "./MinimalLayoutPreferences.island";
8
+ import { profilePreferencesMessages } from "./profile-preferences-messages";
7
9
  import TimezoneCookie from "./TimezoneCookie.island";
8
10
 
11
+ /**
12
+ * @deprecated The language and theme settings always sit in the footer; a
13
+ * position is treated like `true`.
14
+ */
9
15
  export type MinimalLayoutPreferencePosition = "top-left" | "top-right" | "bottom-left" | "bottom-right";
10
16
 
11
17
  export type MinimalLayoutProps = {
12
18
  children: JSX.Element;
13
19
  c: MinimalLayoutContext;
14
- /** Position the shared language/theme control, or disable it entirely. */
15
- preferences?: MinimalLayoutPreferencePosition | false;
16
- };
17
-
18
- const menuPosition = (position: MinimalLayoutPreferencePosition) => {
19
- if (position === "top-left") return "bottom-right" as const;
20
- if (position === "top-right") return "bottom-left" as const;
21
- if (position === "bottom-left") return "top-right" as const;
22
- return "top-left" as const;
20
+ /**
21
+ * Render the footer with legal links and the language and theme settings
22
+ * (default), or `false` for embeds and fixed presentation surfaces.
23
+ */
24
+ preferences?: boolean | MinimalLayoutPreferencePosition;
23
25
  };
24
26
 
25
27
  export default function MinimalLayout(props: MinimalLayoutProps) {
26
28
  const cookie = props.c.req.raw.headers.get("Cookie") ?? "";
27
29
  const theme = readThemeFromCookieHeader(cookie);
28
30
  const locale = getLocale(props.c);
29
- const preferences = props.preferences === undefined ? "bottom-right" : props.preferences;
30
31
  props.c.get("page").theme = theme;
31
32
 
33
+ if (props.preferences === false) {
34
+ return (
35
+ <LocaleProvider locale={locale}>
36
+ <TimezoneCookie />
37
+ {props.children}
38
+ </LocaleProvider>
39
+ );
40
+ }
41
+
42
+ const t = profilePreferencesMessages.resolve([locale]).t;
43
+ // Aggregate every running app's legal links like Layout (last wins on
44
+ // duplicate href). Unlike Layout, MinimalLayout does not require
45
+ // middleware.runtime(); without it the footer has no legal links.
46
+ const apps = resolveAppPresentations(props.c.get("runtime")?.apps ?? [], locale);
47
+ const legalLinks = [...new Map(apps.flatMap((app) => (app.legalLinks ?? []).map((link) => [link.href, link] as const))).values()];
48
+
32
49
  return (
33
50
  <LocaleProvider locale={locale}>
34
51
  <TimezoneCookie />
35
- {props.children}
36
- {preferences !== false && (
37
- <LayoutPreferences
38
- class={`minimal-layout-preferences minimal-layout-preferences--${preferences}`}
39
- initialTheme={theme}
40
- position={menuPosition(preferences)}
41
- triggerClass="minimal-layout-preferences__trigger"
42
- />
43
- )}
52
+ <div class="minimal-layout">
53
+ {props.children}
54
+ <footer class="minimal-layout-footer">
55
+ {legalLinks.length > 0 && (
56
+ <nav class="minimal-layout-footer__links" aria-label={t.legalLinks}>
57
+ {legalLinks.map((link) => (
58
+ // A new tab keeps a started upload or a filled-in form on this page.
59
+ <a href={link.href} target="_blank" rel="noopener">
60
+ {link.label}
61
+ </a>
62
+ ))}
63
+ </nav>
64
+ )}
65
+ <MinimalLayoutPreferences initialTheme={theme} />
66
+ </footer>
67
+ </div>
44
68
  </LocaleProvider>
45
69
  );
46
70
  }
@@ -0,0 +1,48 @@
1
+ import { Button, Dropdown, useLocale } from "@k2b/ui";
2
+ import { type ProfilePreferenceLocale, profilePreferenceLocale, setLocalePreference } from "../browser/locale-preference";
3
+ import { canonicalLocale } from "../shared/locale";
4
+ import type { CloudTheme } from "../shared/theme";
5
+ import { createPreferenceController } from "./preference-controller";
6
+
7
+ /**
8
+ * Labeled language and theme controls for the MinimalLayout footer. The
9
+ * language trigger names the current language; the theme button names the
10
+ * mode it switches to, like every other Cloud preference menu.
11
+ */
12
+ export default function MinimalLayoutPreferences(props: { initialTheme: CloudTheme }) {
13
+ const locale = useLocale();
14
+ const preferences = createPreferenceController(props.initialTheme, locale);
15
+ const t = preferences.messages;
16
+ const language = () => profilePreferenceLocale(locale());
17
+ const languageName = () => (language() === "de" ? t().switchToGerman : t().switchToEnglish);
18
+ // Keep a regional locale such as en-GB when its language is chosen again,
19
+ // but let a visitor on an unsupported locale such as fr-FR, who reads the
20
+ // English fallback, still choose English explicitly.
21
+ const choose = (next: ProfilePreferenceLocale) => {
22
+ if (canonicalLocale(locale())?.split("-")[0] !== next) setLocalePreference(next);
23
+ };
24
+
25
+ return (
26
+ <div class="minimal-layout-footer__preferences">
27
+ <Dropdown.Root
28
+ label={t().language}
29
+ position="top-right"
30
+ width="10rem"
31
+ items={[
32
+ { label: t().switchToGerman, choice: "radio", checked: () => language() === "de", action: () => choose("de") },
33
+ { label: t().switchToEnglish, choice: "radio", checked: () => language() === "en", action: () => choose("en") },
34
+ ]}
35
+ >
36
+ <Dropdown.Trigger label={`${t().language}: ${languageName()}`} size="sm" tooltip={false} variant="ghost">
37
+ <i class="ti ti-language" aria-hidden="true" />
38
+ {languageName()}
39
+ <i class="ti ti-chevron-down" aria-hidden="true" />
40
+ </Dropdown.Trigger>
41
+ </Dropdown.Root>
42
+ <Button onClick={preferences.toggleTheme} size="sm" variant="ghost">
43
+ <i class={preferences.theme() === "light" ? "ti ti-moon" : "ti ti-sun-high"} aria-hidden="true" />
44
+ {preferences.themeLabel()}
45
+ </Button>
46
+ </div>
47
+ );
48
+ }
@@ -17,7 +17,7 @@ export const renderPageError = (c: Context, status: PageErrorStatus, options: Pa
17
17
  return () =>
18
18
  options.layout === "minimal" ? (
19
19
  <MinimalLayout c={c}>
20
- <main class="min-h-screen bg-[var(--ui-canvas)] p-[var(--ui-space-shell)]">
20
+ <main class="flex-1 bg-[var(--ui-canvas)] p-[var(--ui-space-shell)]">
21
21
  <NotFoundState code={String(status)} title={title} description={description} action={action} />
22
22
  </main>
23
23
  </MinimalLayout>
@@ -6,6 +6,8 @@ export const profilePreferencesMessages = i18n.define({
6
6
  en: {
7
7
  menuLabel: "Profile and preferences",
8
8
  preferencesMenuLabel: "Appearance and language",
9
+ language: "Language",
10
+ legalLinks: "Legal",
9
11
  profileSettings: "Profile settings",
10
12
  signOut: "Sign out",
11
13
  signOutFailed: "Sign out failed. Please try again.",
@@ -17,6 +19,8 @@ export const profilePreferencesMessages = i18n.define({
17
19
  de: {
18
20
  menuLabel: "Profil und Einstellungen",
19
21
  preferencesMenuLabel: "Darstellung und Sprache",
22
+ language: "Sprache",
23
+ legalLinks: "Rechtliches",
20
24
  profileSettings: "Profileinstellungen",
21
25
  signOut: "Abmelden",
22
26
  signOutFailed: "Abmelden fehlgeschlagen. Bitte versuche es erneut.",
@@ -49,12 +49,14 @@ export function readWorkspaceNavigation(): WorkspaceNavigationState | undefined
49
49
  try {
50
50
  const value: unknown = JSON.parse(owner.textContent ?? "");
51
51
  if (!value || typeof value !== "object" || !("items" in value) || !validItems(value.items)) return;
52
- // SSR links work before the app island loads; action handlers are not yet available.
52
+ // SSR links work before the app island loads; action and disclosure handlers are not yet available,
53
+ // so the owner's folds only set where the local disclosure starts.
53
54
  const links = (items: readonly NavigationItem[]): NavigationItem[] =>
54
55
  items.map((item) => ({
55
56
  ...item,
56
57
  disabled: item.disabled || Boolean(item.action && !item.href),
57
58
  ...(item.href ? { action: undefined } : {}),
59
+ ...(item.expanded !== undefined ? { expanded: undefined, defaultExpanded: item.expanded } : {}),
58
60
  ...(item.children ? { children: links(item.children) } : {}),
59
61
  ...(item.actions ? { actions: links(item.actions) } : {}),
60
62
  }));
@@ -903,36 +903,85 @@
903
903
  }
904
904
  }
905
905
 
906
- /* MinimalLayout owns no page geometry. Its optional preference trigger is the
907
- only positioned element and respects device safe areas in every corner. */
908
- .minimal-layout-preferences {
909
- position: fixed;
910
- z-index: 50;
906
+ /* MinimalLayout stacks the page content and its footer in a column at least
907
+ one viewport tall, so the footer ends a short page and follows a long one.
908
+ Content that should fill the remaining height grows with flex-1. */
909
+ .minimal-layout {
910
+ display: flex;
911
+ min-height: 100vh;
912
+ min-height: 100dvh;
913
+ flex-direction: column;
914
+ }
915
+
916
+ .minimal-layout-footer {
917
+ display: flex;
918
+ flex-wrap: wrap;
919
+ align-items: center;
920
+ justify-content: center;
921
+ gap: 0.25rem 1rem;
922
+ margin-top: auto;
923
+ padding: 0.75rem max(1rem, env(safe-area-inset-right)) max(0.75rem, env(safe-area-inset-bottom))
924
+ max(1rem, env(safe-area-inset-left));
925
+ color: var(--k2b-text-muted);
926
+ font-size: 0.75rem;
927
+ line-height: 1rem;
928
+ }
929
+
930
+ .minimal-layout-footer__links,
931
+ .minimal-layout-footer__preferences {
932
+ display: flex;
933
+ flex-wrap: wrap;
934
+ align-items: center;
935
+ justify-content: center;
936
+ }
937
+
938
+ .minimal-layout-footer__links {
939
+ column-gap: 1rem;
911
940
  }
912
941
 
913
- .minimal-layout-preferences--top-left {
914
- top: max(0.75rem, env(safe-area-inset-top));
915
- left: max(0.75rem, env(safe-area-inset-left));
942
+ .minimal-layout-footer__preferences {
943
+ gap: 0.25rem;
916
944
  }
917
945
 
918
- .minimal-layout-preferences--top-right {
919
- top: max(0.75rem, env(safe-area-inset-top));
920
- right: max(0.75rem, env(safe-area-inset-right));
946
+ .minimal-layout-footer__links a {
947
+ display: inline-flex;
948
+ min-height: 1.75rem;
949
+ align-items: center;
950
+ border-radius: var(--k2b-radius-control);
951
+ color: inherit;
952
+ transition: color 150ms ease-out;
953
+ }
954
+
955
+ .minimal-layout-footer__links a:hover {
956
+ color: var(--k2b-text);
957
+ }
958
+
959
+ .minimal-layout-footer__links a:focus-visible {
960
+ outline: 2px solid var(--k2b-focus-ring);
961
+ outline-offset: 2px;
921
962
  }
922
963
 
923
- .minimal-layout-preferences--bottom-left {
924
- bottom: max(0.75rem, env(safe-area-inset-bottom));
925
- left: max(0.75rem, env(safe-area-inset-left));
964
+ .k2b-ui .minimal-layout-footer .k2b-button[data-size] {
965
+ color: inherit;
966
+ font-size: inherit;
967
+ }
968
+
969
+ .k2b-ui .minimal-layout-footer .k2b-button[data-size]:hover {
970
+ color: var(--k2b-text);
926
971
  }
927
972
 
928
- .minimal-layout-preferences--bottom-right {
929
- right: max(0.75rem, env(safe-area-inset-right));
930
- bottom: max(0.75rem, env(safe-area-inset-bottom));
973
+ /* Finger-sized footer targets on touch screens. */
974
+ @media (any-pointer: coarse) {
975
+ .minimal-layout-footer__links a,
976
+ .k2b-ui .minimal-layout-footer .k2b-button[data-size] {
977
+ min-height: 2.75rem;
978
+ }
931
979
  }
932
980
 
933
- .minimal-layout-preferences__trigger {
934
- box-shadow: var(--ui-shadow-float);
935
- backdrop-filter: blur(0.5rem);
981
+ @media (prefers-reduced-motion: reduce) {
982
+ .minimal-layout-footer__links a {
983
+ transition: none;
984
+ }
936
985
  }
937
986
 
938
987
  /* Workspace navigation chrome and resource identity. */