@cosmicdrift/kumiko-renderer-web 0.1.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 (58) hide show
  1. package/package.json +63 -0
  2. package/src/__tests__/avatar.test.tsx +34 -0
  3. package/src/__tests__/combobox.test.tsx +240 -0
  4. package/src/__tests__/config-edit.test.tsx +172 -0
  5. package/src/__tests__/create-app.test.tsx +261 -0
  6. package/src/__tests__/date-input.test.tsx +91 -0
  7. package/src/__tests__/default-app-shell.test.tsx +60 -0
  8. package/src/__tests__/dispatcher-context.test.tsx +101 -0
  9. package/src/__tests__/dispatcher-status-wiring.test.tsx +119 -0
  10. package/src/__tests__/kumiko-screen.test.tsx +1014 -0
  11. package/src/__tests__/language-switcher.test.tsx +100 -0
  12. package/src/__tests__/money-input.test.tsx +232 -0
  13. package/src/__tests__/nav-base-path.test.tsx +388 -0
  14. package/src/__tests__/nav-search-params.test.tsx +88 -0
  15. package/src/__tests__/nav-tree.test.tsx +183 -0
  16. package/src/__tests__/nav.test.tsx +253 -0
  17. package/src/__tests__/primitives.test.tsx +936 -0
  18. package/src/__tests__/render-edit.test.tsx +178 -0
  19. package/src/__tests__/render-list-column-renderer.test.tsx +124 -0
  20. package/src/__tests__/render-list-debounce.test.tsx +128 -0
  21. package/src/__tests__/render-list.test.tsx +151 -0
  22. package/src/__tests__/sidebar.test.tsx +59 -0
  23. package/src/__tests__/test-utils.tsx +144 -0
  24. package/src/__tests__/theme-toggle.test.tsx +101 -0
  25. package/src/__tests__/toast.test.tsx +162 -0
  26. package/src/__tests__/use-form.test.tsx +112 -0
  27. package/src/__tests__/use-query-live.test.tsx +152 -0
  28. package/src/__tests__/use-query.test.tsx +88 -0
  29. package/src/__tests__/use-store.test.tsx +139 -0
  30. package/src/__tests__/workspace-shell.test.tsx +772 -0
  31. package/src/app/browser-locale.ts +85 -0
  32. package/src/app/client-plugin.tsx +63 -0
  33. package/src/app/create-app.tsx +380 -0
  34. package/src/app/nav.tsx +226 -0
  35. package/src/index.ts +137 -0
  36. package/src/layout/app-layout.tsx +35 -0
  37. package/src/layout/avatar.tsx +93 -0
  38. package/src/layout/default-app-shell.tsx +74 -0
  39. package/src/layout/language-switcher.tsx +101 -0
  40. package/src/layout/nav-tree.tsx +281 -0
  41. package/src/layout/profile-menu.tsx +40 -0
  42. package/src/layout/sidebar.tsx +65 -0
  43. package/src/layout/theme-toggle.tsx +44 -0
  44. package/src/layout/topbar.tsx +22 -0
  45. package/src/layout/workspace-shell.tsx +282 -0
  46. package/src/layout/workspace-switcher.tsx +62 -0
  47. package/src/lib/cn.ts +10 -0
  48. package/src/primitives/action-menu.tsx +111 -0
  49. package/src/primitives/combobox.tsx +261 -0
  50. package/src/primitives/date-input.tsx +165 -0
  51. package/src/primitives/dialog.tsx +119 -0
  52. package/src/primitives/dropdown-menu.tsx +103 -0
  53. package/src/primitives/index.tsx +1271 -0
  54. package/src/primitives/money-input.tsx +192 -0
  55. package/src/primitives/toast.tsx +166 -0
  56. package/src/sse/live-events.ts +90 -0
  57. package/src/styles.css +113 -0
  58. package/src/tokens.ts +63 -0
@@ -0,0 +1,226 @@
1
+ // Browser-Impl der Navigation: HTML5-History + popstate subscribe.
2
+ // Types + Context + useNav leben im shared `@cosmicdrift/kumiko-renderer`; dieser
3
+ // File liefert nur die Web-spezifische NavApi-Instanz und die
4
+ // `<KumikoLink>` Anchor-Komponente.
5
+ //
6
+ // Bewusst KEIN createStore: Source-of-truth ist `window.location.pathname`
7
+ // (extern, kann via replaceState außerhalb unserer Kontrolle wechseln).
8
+ // Ein Store wäre entweder eine zweite Wahrheit (Drift-Risiko) oder ein
9
+ // reiner Tick-Counter (Anti-Pattern — createStore ist State-Holder, kein
10
+ // Event-Bus). Hand-rolled Listener-Set ist hier idiomatisch: zwei
11
+ // Notify-Trigger (eigenes pushPath, popstate-Event), nicht generalisierbar.
12
+
13
+ import {
14
+ formatPath,
15
+ type NavApi,
16
+ type NavTarget,
17
+ parsePath,
18
+ useNav,
19
+ } from "@cosmicdrift/kumiko-renderer";
20
+ import {
21
+ type AnchorHTMLAttributes,
22
+ type MouseEvent,
23
+ type ReactNode,
24
+ useCallback,
25
+ useMemo,
26
+ useSyncExternalStore,
27
+ } from "react";
28
+
29
+ // basePath erlaubt es, die App unter einem URL-Prefix zu mounten
30
+ // (z.B. `/admin`). Read-Pfad strippt den Prefix vor dem parsePath,
31
+ // Write-Pfad prepend'd ihn vor dem pushState/replaceState/href.
32
+ //
33
+ // Wenn die URL nicht im basePath liegt (z.B. /marketing/foo bei
34
+ // basePath="/admin"), liefert stripBasePath `undefined` — useBrowserNavApi
35
+ // gibt dann route=undefined zurück, die App rendert ihren "outside"-State
36
+ // (Not-Found, Marketing-Layer, Server-Routing-Pickup, …). Es gibt KEIN
37
+ // Auto-Redirect zur App-Root — die Host-App entscheidet selbst.
38
+ function normalizeBasePath(raw: string | undefined): string {
39
+ if (raw === undefined || raw === "" || raw === "/") return "";
40
+ const withLeading = raw.startsWith("/") ? raw : `/${raw}`;
41
+ return withLeading.endsWith("/") ? withLeading.slice(0, -1) : withLeading;
42
+ }
43
+
44
+ // Returns the in-app path (basePath-relativ) wenn `path` im basePath liegt,
45
+ // sonst `undefined`. Strict-segment-Boundary: "/administrator" matcht
46
+ // nicht "/admin" — sonst würde der Prefix-Check auf String-Ebene falsche
47
+ // Treffer liefern.
48
+ function stripBasePath(path: string, basePath: string): string | undefined {
49
+ if (basePath === "") return path;
50
+ if (path === basePath) return "/";
51
+ if (path.startsWith(`${basePath}/`)) return path.slice(basePath.length);
52
+ return undefined;
53
+ }
54
+
55
+ function prependBasePath(path: string, basePath: string): string {
56
+ if (basePath === "") return path;
57
+ // formatPath liefert immer absoluten in-app-Pfad: "/" oder "/screen-id"
58
+ // oder "/screen-id/entity-id". "/" → einfach basePath, sonst concat.
59
+ if (path === "/" || path === "") return basePath;
60
+ return `${basePath}${path}`;
61
+ }
62
+
63
+ // pushState feuert keinen popstate — wir halten einen eigenen
64
+ // Listener-Set, den navigate() notifiziert. popstate (Back/Forward)
65
+ // läuft über einen window-Listener, den wir einmalig verdrahten.
66
+ const listeners = new Set<() => void>();
67
+ let popstateWired = false;
68
+
69
+ function ensurePopstateWired(): void {
70
+ if (popstateWired) return;
71
+ if (typeof window === "undefined") return;
72
+ window.addEventListener("popstate", () => {
73
+ for (const l of listeners) l();
74
+ });
75
+ popstateWired = true;
76
+ }
77
+
78
+ function subscribe(listener: () => void): () => void {
79
+ ensurePopstateWired();
80
+ listeners.add(listener);
81
+ return () => {
82
+ listeners.delete(listener);
83
+ };
84
+ }
85
+
86
+ function readPath(): string {
87
+ return typeof window !== "undefined" ? window.location.pathname : "/";
88
+ }
89
+
90
+ // Read der aktuellen ?key=value-Pairs als Plain-Record. URLSearchParams-
91
+ // Iterator gibt String/String — wir kollabieren auf "letzter Wert
92
+ // gewinnt" wenn ein Key mehrfach im Query auftaucht (`?a=1&a=2` → "2").
93
+ // Multi-Value-Lists sind kein Use-Case in Kumiko-Filter-State; explizit
94
+ // dokumentieren statt unspezifizierten Behavior zu liefern.
95
+ function readSearch(): string {
96
+ return typeof window !== "undefined" ? window.location.search : "";
97
+ }
98
+
99
+ function parseSearchParams(search: string): Readonly<Record<string, string>> {
100
+ const out: Record<string, string> = {};
101
+ if (search === "") return out;
102
+ const params = new URLSearchParams(search);
103
+ for (const [k, v] of params.entries()) out[k] = v;
104
+ return out;
105
+ }
106
+
107
+ // Mergt updates in den aktuellen ?-String. `null` löscht, sonst
108
+ // überschreibt. Verwendet replaceState (KEIN push) — Sort/Filter-Toggles
109
+ // flutten sonst die Back-Navigation und User clicked sich durch
110
+ // dutzende Zwischen-States um zur vorherigen Seite zu kommen.
111
+ function applySearchParamUpdates(updates: Readonly<Record<string, string | null>>): void {
112
+ if (typeof window === "undefined") return;
113
+ const params = new URLSearchParams(window.location.search);
114
+ for (const [key, value] of Object.entries(updates)) {
115
+ if (value === null) {
116
+ params.delete(key);
117
+ } else {
118
+ params.set(key, value);
119
+ }
120
+ }
121
+ const next = params.toString();
122
+ const nextSearch = next === "" ? "" : `?${next}`;
123
+ if (nextSearch === window.location.search) return;
124
+ const url = `${window.location.pathname}${nextSearch}${window.location.hash}`;
125
+ window.history.replaceState(null, "", url);
126
+ for (const l of listeners) l();
127
+ }
128
+
129
+ function pushPath(path: string): void {
130
+ if (typeof window === "undefined") return;
131
+ // Nur pushen wenn sich der Pfad wirklich ändert — doppelte navigate()
132
+ // Aufrufe mit demselben Ziel sollen nicht die History fluten.
133
+ if (window.location.pathname === path) return;
134
+ window.history.pushState(null, "", path);
135
+ for (const l of listeners) l();
136
+ }
137
+
138
+ function replacePath(path: string): void {
139
+ if (typeof window === "undefined") return;
140
+ // No path-change short-circuit here: callers explicitly chose replace
141
+ // to avoid creating a history entry, even when the URL is identical.
142
+ // (pushPath skips no-op pushes; replacePath honors the call so the
143
+ // entry-stack semantics stay predictable.)
144
+ window.history.replaceState(null, "", path);
145
+ for (const l of listeners) l();
146
+ }
147
+
148
+ /** React-Hook der eine NavApi aus der Browser-History baut. Sollte
149
+ * einmal im App-Root aufgerufen und als value an den shared
150
+ * `<NavProvider>` durchgereicht werden — createKumikoApp tut das.
151
+ *
152
+ * hasWorkspaces aus dem Schema (schema.workspaces non-empty) entscheidet,
153
+ * ob das erste URL-Segment als Workspace-id parsed wird. Pure Pass-
154
+ * through an parsePath; formatPath checkt selbst auf target.workspaceId.
155
+ *
156
+ * basePath mounted die App unter einem URL-Prefix (z.B. "/admin"). Read-
157
+ * Pfad strippt vor parsePath, Write-Pfad prepend'd vor pushState/href.
158
+ * URLs außerhalb des basePath liefern route=undefined, ohne Auto-Redirect.
159
+ *
160
+ * Achtung — Ambiguität bei route=undefined mit basePath:
161
+ * Sowohl die App-Root (URL === basePath, z.B. "/admin") als auch URLs
162
+ * außerhalb des basePath (z.B. "/marketing") liefern route=undefined. Eine
163
+ * App die zwischen "render Default-Screen" und "render Not-Found"
164
+ * unterscheiden muss, muss zusätzlich `window.location.pathname` prüfen:
165
+ *
166
+ * if (window.location.pathname.startsWith("/admin")) {
167
+ * // in-app, aber an der Root → Default-Screen
168
+ * } else {
169
+ * // out-of-app → Not-Found / Marketing-Layer
170
+ * }
171
+ *
172
+ * Im non-basePath-Modus ergibt sich diese Ambiguität nicht — out-of-app
173
+ * ist dort kein Konzept. */
174
+ export function useBrowserNavApi(options?: {
175
+ readonly hasWorkspaces?: boolean;
176
+ readonly basePath?: string;
177
+ }): NavApi {
178
+ const path = useSyncExternalStore(subscribe, readPath, () => "/");
179
+ // Search wird über denselben Listener-Set notifiziert (popstate +
180
+ // unsere replaceState-Calls), also reichen wir denselben subscribe
181
+ // durch. Beide Snapshots werden zusammen recomputed — kein Drift
182
+ // zwischen Pfad und Query.
183
+ const search = useSyncExternalStore(subscribe, readSearch, () => "");
184
+ const hasWorkspaces = options?.hasWorkspaces === true;
185
+ const basePath = useMemo(() => normalizeBasePath(options?.basePath), [options?.basePath]);
186
+ const searchParams = useMemo(() => parseSearchParams(search), [search]);
187
+ const inAppPath = useMemo(() => stripBasePath(path, basePath), [path, basePath]);
188
+ return useMemo<NavApi>(
189
+ () => ({
190
+ route: inAppPath === undefined ? undefined : parsePath(inAppPath, hasWorkspaces),
191
+ navigate: (target) => pushPath(prependBasePath(formatPath(target), basePath)),
192
+ replace: (target) => replacePath(prependBasePath(formatPath(target), basePath)),
193
+ hrefFor: (target) => prependBasePath(formatPath(target), basePath),
194
+ searchParams,
195
+ setSearchParams: applySearchParamUpdates,
196
+ }),
197
+ [inAppPath, hasWorkspaces, basePath, searchParams],
198
+ );
199
+ }
200
+
201
+ // ---- KumikoLink (Anchor-basiert, nur Web) ----
202
+
203
+ export type KumikoLinkProps = Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "href"> & {
204
+ readonly to: NavTarget;
205
+ };
206
+
207
+ export function KumikoLink({ to, onClick, children, ...rest }: KumikoLinkProps): ReactNode {
208
+ const nav = useNav();
209
+ const handleClick = useCallback(
210
+ (e: MouseEvent<HTMLAnchorElement>) => {
211
+ onClick?.(e);
212
+ if (e.defaultPrevented) return;
213
+ // Standard-Browser-Verhalten für Cmd/Ctrl/Shift/Alt + Middle-Click
214
+ // erhalten — nur der plain-left-click landet bei navigate().
215
+ if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
216
+ e.preventDefault();
217
+ nav.navigate(to);
218
+ },
219
+ [nav, to, onClick],
220
+ );
221
+ return (
222
+ <a href={nav.hrefFor(to)} onClick={handleClick} {...rest}>
223
+ {children}
224
+ </a>
225
+ );
226
+ }
package/src/index.ts ADDED
@@ -0,0 +1,137 @@
1
+ // Public entry für @cosmicdrift/kumiko-renderer-web. Re-exportiert die shared-
2
+ // API aus @cosmicdrift/kumiko-renderer damit Samples nur ein Paket importieren
3
+ // müssen, und fügt die Web-spezifischen Helpers dazu: createKumikoApp
4
+ // (react-dom-bootstrap), defaultPrimitives (HTML), useBrowserNavApi
5
+ // (window.history), createEventSourceLiveEvents, KumikoLink.
6
+
7
+ // --- Shared re-exports (Components, Hooks, Types, Contexts) ---
8
+ export type {
9
+ AppPrimitives,
10
+ AppSchema,
11
+ AppTokens,
12
+ BannerProps,
13
+ ButtonProps,
14
+ ColorTokens,
15
+ CorePrimitives,
16
+ CoreTokens,
17
+ DataTableProps,
18
+ DispatcherProviderProps,
19
+ FeatureSchema,
20
+ FieldProps,
21
+ FormProps,
22
+ GridCellProps,
23
+ GridProps,
24
+ HeadingProps,
25
+ InputProps,
26
+ KumikoScreenProps,
27
+ LiveEvent,
28
+ LiveEventSubscriber,
29
+ LiveEventsProviderProps,
30
+ LocaleProviderProps,
31
+ NavApi,
32
+ NavProviderProps,
33
+ NavRoute,
34
+ NavTarget,
35
+ PrimitivesProviderProps,
36
+ PrimitivesRegistry,
37
+ RadiusTokens,
38
+ RenderEditProps,
39
+ RenderFieldProps,
40
+ RenderListProps,
41
+ SectionProps,
42
+ TextProps,
43
+ ThemeMode,
44
+ Tokens,
45
+ TokensApi,
46
+ TokensProviderProps,
47
+ TranslationBundle,
48
+ TranslationsByLocale,
49
+ UseFormOptions,
50
+ UseFormResult,
51
+ UseQueryOptions,
52
+ UseQueryResult,
53
+ WorkspaceSchema,
54
+ } from "@cosmicdrift/kumiko-renderer";
55
+ export {
56
+ createStaticLocaleResolver,
57
+ cssVarTokens,
58
+ DispatcherProvider,
59
+ formatPath,
60
+ KumikoScreen,
61
+ LiveEventsProvider,
62
+ LocaleProvider,
63
+ NavProvider,
64
+ PrimitivesProvider,
65
+ parsePath,
66
+ qualifyScreenId,
67
+ RenderEdit,
68
+ RenderField,
69
+ RenderList,
70
+ TokensProvider,
71
+ useDispatcher,
72
+ useDispatcherStatus,
73
+ useForm,
74
+ useLiveEvents,
75
+ useLocale,
76
+ useNav,
77
+ usePrimitives,
78
+ useQuery,
79
+ useStore,
80
+ useStoreSelector,
81
+ useTokenController,
82
+ useTokens,
83
+ useTranslation,
84
+ } from "@cosmicdrift/kumiko-renderer";
85
+ // --- Web-platform specifics ---
86
+ export { createBrowserLocaleResolver } from "./app/browser-locale";
87
+ export type { ClientFeatureDefinition } from "./app/client-plugin";
88
+ export type { CreateKumikoAppOptions } from "./app/create-app";
89
+ export { createKumikoApp } from "./app/create-app";
90
+ export type { KumikoLinkProps } from "./app/nav";
91
+ export { KumikoLink, useBrowserNavApi } from "./app/nav";
92
+ export type { AppLayoutProps } from "./layout/app-layout";
93
+ export { AppLayout } from "./layout/app-layout";
94
+ export type { AvatarProps, AvatarSize } from "./layout/avatar";
95
+ export { Avatar } from "./layout/avatar";
96
+ export type { DefaultAppShellProps } from "./layout/default-app-shell";
97
+ export { DefaultAppShell } from "./layout/default-app-shell";
98
+ export type { LanguageSwitcherProps, LocaleOption } from "./layout/language-switcher";
99
+ export { LanguageSwitcher } from "./layout/language-switcher";
100
+ export type { NavTreeProps } from "./layout/nav-tree";
101
+ export { buildNavRegistrySlice, NavTree } from "./layout/nav-tree";
102
+ export type { ProfileMenuItem, ProfileMenuProps } from "./layout/profile-menu";
103
+ export { ProfileMenu } from "./layout/profile-menu";
104
+ export type { SidebarProps } from "./layout/sidebar";
105
+ export { Sidebar } from "./layout/sidebar";
106
+ export type { ThemeToggleProps } from "./layout/theme-toggle";
107
+ export { ThemeToggle } from "./layout/theme-toggle";
108
+ export type { TopbarProps } from "./layout/topbar";
109
+ export { Topbar } from "./layout/topbar";
110
+ export type { WorkspaceShellProps, WorkspaceShellUser } from "./layout/workspace-shell";
111
+ export { filterByAccess, resolveDefaultId, WorkspaceShell } from "./layout/workspace-shell";
112
+ export type { WorkspaceSwitcherProps } from "./layout/workspace-switcher";
113
+ export { WorkspaceSwitcher } from "./layout/workspace-switcher";
114
+ export { cn } from "./lib/cn";
115
+ export { defaultPrimitives } from "./primitives";
116
+ export type { ActionMenuProps, MenuItemDef } from "./primitives/action-menu";
117
+ export { ActionMenu } from "./primitives/action-menu";
118
+ export {
119
+ DropdownMenu,
120
+ DropdownMenuCheckboxItem,
121
+ DropdownMenuContent,
122
+ DropdownMenuItem,
123
+ DropdownMenuLabel,
124
+ DropdownMenuPortal,
125
+ DropdownMenuSeparator,
126
+ DropdownMenuTrigger,
127
+ } from "./primitives/dropdown-menu";
128
+ export type { ToastOptions, ToastProviderProps, ToastVariant } from "./primitives/toast";
129
+ export { ToastProvider, useToast } from "./primitives/toast";
130
+ export type { CreateEventSourceLiveEventsOptions } from "./sse/live-events";
131
+ export { createEventSourceLiveEvents } from "./sse/live-events";
132
+ export {
133
+ applyTokensToCssVars,
134
+ defaultTokens,
135
+ lightTokens,
136
+ useBrowserTokensApi,
137
+ } from "./tokens";
@@ -0,0 +1,35 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ // Linear-Pattern: Sidebar ist Geschwister von <Topbar+Main>-Spalte,
4
+ // nimmt die volle Höhe. Wenn ein Topbar gesetzt ist, lebt er NUR über
5
+ // der Main-Spalte rechts neben der Sidebar. So wirkt die Sidebar als
6
+ // kontinuierlicher Chrome-Streifen mit eigener Identität, statt unter
7
+ // einer durchgehenden Kopfzeile geklemmt zu werden.
8
+
9
+ export type AppLayoutProps = {
10
+ /** Optional. Lebt nur über dem Main-Bereich, nicht über der Sidebar. */
11
+ readonly topbar?: ReactNode;
12
+ readonly sidebar?: ReactNode;
13
+ readonly children: ReactNode;
14
+ readonly testId?: string;
15
+ };
16
+
17
+ export function AppLayout({ topbar, sidebar, children, testId }: AppLayoutProps): ReactNode {
18
+ return (
19
+ <div
20
+ data-testid={testId}
21
+ data-kumiko-layout="app"
22
+ className="flex min-h-screen flex-row bg-background text-foreground"
23
+ >
24
+ {sidebar}
25
+ <div className="flex flex-1 min-w-0 flex-col">
26
+ {topbar}
27
+ {/* main hat KEIN Padding — Screens (Form, Liste, Demo-Pages)
28
+ managen ihr Padding selber, damit Action-Bars edge-to-edge
29
+ spannen können ohne Negative-Margin-Tricks die mit
30
+ `position: sticky` kollidieren. */}
31
+ <main className="flex-1 overflow-auto">{children}</main>
32
+ </div>
33
+ </div>
34
+ );
35
+ }
@@ -0,0 +1,93 @@
1
+ // Avatar-Pill mit Initials. Color-coded background basierend auf
2
+ // string-hash damit der gleiche User immer dieselbe Farbe bekommt
3
+ // (Linear-Pattern). 8 Hue-Targets für gute Distinktion ohne über-
4
+ // bunte Sidebar.
5
+ //
6
+ // Nutzung: `<Avatar id="user-123" label="Daniel Hennig" size="md" />`.
7
+ // `id` ist die Hash-Quelle für die Farbe; `label` wird für Initials
8
+ // + aria-label genutzt. Kein Image-Slot im MVP — kommt wenn Apps
9
+ // echte Avatar-URLs haben.
10
+
11
+ import { type ReactNode, useMemo } from "react";
12
+ import { cn } from "../lib/cn";
13
+
14
+ export type AvatarSize = "sm" | "md" | "lg";
15
+
16
+ export type AvatarProps = {
17
+ /** Stable Identifier — Hash-Quelle für die Background-Farbe. Üblich
18
+ * user.id; bei rein labelbasierten Avataren auch der label-String. */
19
+ readonly id: string;
20
+ /** Voller Name oder Email — Initials werden daraus extrahiert. */
21
+ readonly label: string;
22
+ readonly size?: AvatarSize;
23
+ readonly testId?: string;
24
+ };
25
+
26
+ // Tailwind-Klassen pro Hue. Die Background-Lightness ist dezent (550-
27
+ // 600 für saturation), die Foreground-Lightness ist hell (50-100) für
28
+ // Kontrast. Passt zu beiden Modes weil die Tokens via CSS-vars greifen
29
+ // — aber: feste hex-Farben hier sind stabiler als token-mapping
30
+ // (Avatar-Farbe sollte deterministic sein, nicht theme-abhängig).
31
+ const COLOR_CLASSES = [
32
+ "bg-rose-600 text-rose-50",
33
+ "bg-orange-600 text-orange-50",
34
+ "bg-amber-600 text-amber-50",
35
+ "bg-emerald-600 text-emerald-50",
36
+ "bg-teal-600 text-teal-50",
37
+ "bg-sky-600 text-sky-50",
38
+ "bg-indigo-600 text-indigo-50",
39
+ "bg-fuchsia-600 text-fuchsia-50",
40
+ ] as const;
41
+
42
+ const SIZE_CLASSES: Record<AvatarSize, string> = {
43
+ sm: "size-5 text-[10px]",
44
+ md: "size-6 text-[11px]",
45
+ lg: "size-8 text-xs",
46
+ };
47
+
48
+ function hashCode(str: string): number {
49
+ // djb2-Variante — schnell, deterministic, gut genug verteilt für
50
+ // 8 Buckets. Crypto-Hash wäre Overkill für color-bucketing.
51
+ let hash = 5381;
52
+ for (let i = 0; i < str.length; i++) {
53
+ hash = ((hash << 5) + hash + str.charCodeAt(i)) >>> 0;
54
+ }
55
+ return hash;
56
+ }
57
+
58
+ function pickColor(id: string): string {
59
+ const idx = hashCode(id) % COLOR_CLASSES.length;
60
+ return COLOR_CLASSES[idx] ?? COLOR_CLASSES[0] ?? "bg-muted";
61
+ }
62
+
63
+ function extractInitials(label: string): string {
64
+ // "Daniel Hennig" → "DH". "alice@example.com" → "A". Single-word
65
+ // fällt auf erste 2 Buchstaben zurück ("Daniel" → "DA"). Alles
66
+ // upper-case.
67
+ const trimmed = label.trim();
68
+ if (trimmed.length === 0) return "?";
69
+ const parts = trimmed.split(/\s+/);
70
+ if (parts.length >= 2) {
71
+ return `${(parts[0]?.[0] ?? "").toUpperCase()}${(parts[1]?.[0] ?? "").toUpperCase()}`;
72
+ }
73
+ return trimmed.slice(0, 2).toUpperCase();
74
+ }
75
+
76
+ export function Avatar({ id, label, size = "md", testId }: AvatarProps): ReactNode {
77
+ const colorClass = useMemo(() => pickColor(id), [id]);
78
+ const initials = useMemo(() => extractInitials(label), [label]);
79
+ return (
80
+ <span
81
+ data-testid={testId}
82
+ role="img"
83
+ aria-label={label}
84
+ className={cn(
85
+ "inline-flex items-center justify-center rounded font-semibold uppercase select-none",
86
+ SIZE_CLASSES[size],
87
+ colorClass,
88
+ )}
89
+ >
90
+ {initials}
91
+ </span>
92
+ );
93
+ }
@@ -0,0 +1,74 @@
1
+ // DefaultAppShell — Linear-Pattern: kein globaler Topbar, Sidebar
2
+ // nimmt full-height und trägt die ganze Chrome-Identität. 90% der
3
+ // Apps brauchen exakt das hier.
4
+ //
5
+ // Sidebar-Slots:
6
+ // 1. brand — Workspace-Identity oben (z.B. Logo + Name)
7
+ // 2. sidebarActions — Icon-Row mit Search/Theme/Tenant-Switch etc.
8
+ // 3. NavTree — automatisch aus dem Schema
9
+ // 4. sidebarFooter — Bottom-Slot für Profile/Help/Plan-Banner
10
+ //
11
+ // Apps die feingranulare Kontrolle wollen, gehen direkt auf
12
+ // <AppLayout>/<Sidebar>/<NavTree>. Wer ein Topbar zurück will (z.B.
13
+ // für Multi-Workspace mit Switcher in der Topbar), nutzt
14
+ // <WorkspaceShell> oder baut den Shell selber.
15
+
16
+ import type { AppSchema, FeatureSchema } from "@cosmicdrift/kumiko-renderer";
17
+ import type { ReactNode } from "react";
18
+ import { AppLayout } from "./app-layout";
19
+ import { NavTree } from "./nav-tree";
20
+ import { Sidebar } from "./sidebar";
21
+
22
+ export type DefaultAppShellUser = {
23
+ readonly id: string;
24
+ readonly roles: readonly string[];
25
+ };
26
+
27
+ export type DefaultAppShellProps = {
28
+ /** Header-Slot oben in der Sidebar — Workspace-Identity (Logo,
29
+ * Name, Plan-Tag). Caller liefert frei. */
30
+ readonly brand: ReactNode;
31
+ /** Schema (AppSchema oder legacy FeatureSchema) wird an NavTree
32
+ * durchgereicht; Sidebar-Einträge bauen sich automatisch aus
33
+ * schema.navs (per-Feature) auf. */
34
+ readonly schema: AppSchema | FeatureSchema;
35
+ /** Current user — drives nav role-gating. Ohne user-prop werden
36
+ * role-gated nav-einträge (`access: { roles: [...] }`) komplett
37
+ * ausgeblendet (resolveNavigation behandelt undefined-user als
38
+ * anonymous). Symmetrisch zu WorkspaceShell.user. */
39
+ readonly user?: DefaultAppShellUser;
40
+ /** Icon-Row zwischen Brand und NavTree — typisch Search-Trigger,
41
+ * ThemeToggle, TenantSwitcher. Linear hat hier ~3 Icons in einer
42
+ * horizontalen Reihe. */
43
+ readonly sidebarActions?: ReactNode;
44
+ /** Footer-Slot unten in der Sidebar — Profile-Row, Help-Link,
45
+ * Plan-Banner. Klebt am unteren Rand via `mt-auto`. */
46
+ readonly sidebarFooter?: ReactNode;
47
+ /** Screen-Content der in `main` gerendert wird. */
48
+ readonly children: ReactNode;
49
+ };
50
+
51
+ export function DefaultAppShell({
52
+ brand,
53
+ schema,
54
+ user,
55
+ sidebarActions,
56
+ sidebarFooter,
57
+ children,
58
+ }: DefaultAppShellProps): ReactNode {
59
+ return (
60
+ <AppLayout
61
+ sidebar={
62
+ <Sidebar
63
+ header={brand}
64
+ {...(sidebarActions !== undefined && { actions: sidebarActions })}
65
+ {...(sidebarFooter !== undefined && { footer: sidebarFooter })}
66
+ >
67
+ <NavTree schema={schema} {...(user !== undefined && { user })} />
68
+ </Sidebar>
69
+ }
70
+ >
71
+ {children}
72
+ </AppLayout>
73
+ );
74
+ }
@@ -0,0 +1,101 @@
1
+ // LanguageSwitcher — Dropdown der die App-Locale via
2
+ // LocaleResolver.setLocale umschaltet. Auf Radix-DropdownMenu, gleicher
3
+ // Stack wie UserMenu/TenantSwitcher.
4
+ //
5
+ // Rendert gar nix wenn der Resolver keine setLocale-Methode anbietet
6
+ // (statischer Resolver) — App-Dev sieht dann sofort dass er einen
7
+ // stateful Resolver verdrahten muss, bevor der Switcher UI-sichtbar
8
+ // wird.
9
+ //
10
+ // Icon-Slot optional: das Framework zieht lucide-react nicht selbst
11
+ // rein; eine App die keinen Icon-Import will, kriegt den Globe-
12
+ // Unicode-Glyph (🌐) als Default.
13
+
14
+ import { useLocale } from "@cosmicdrift/kumiko-renderer";
15
+ import { type ReactNode, useMemo } from "react";
16
+ import { cn } from "../lib/cn";
17
+ import {
18
+ DropdownMenu,
19
+ DropdownMenuCheckboxItem,
20
+ DropdownMenuContent,
21
+ DropdownMenuTrigger,
22
+ } from "../primitives/dropdown-menu";
23
+
24
+ export type LocaleOption = {
25
+ /** BCP-47-Code, z.B. "de", "en-US", "fr-CA". Wird 1:1 an
26
+ * resolver.setLocale() weitergereicht. */
27
+ readonly code: string;
28
+ /** Menschenlesbare Anzeige im Dropdown. */
29
+ readonly label: string;
30
+ };
31
+
32
+ export type LanguageSwitcherProps = {
33
+ /** Auswählbare Locales. Reihenfolge = Anzeige-Reihenfolge im Menü. */
34
+ readonly locales: readonly LocaleOption[];
35
+ /** Icon-Slot links neben dem Button-Label. Default: 🌐. */
36
+ readonly icon?: ReactNode;
37
+ /** aria-label + title des Triggers. Default: "Sprache". */
38
+ readonly label?: string;
39
+ readonly testId?: string;
40
+ };
41
+
42
+ export function LanguageSwitcher({
43
+ locales,
44
+ icon = "🌐",
45
+ label = "Sprache",
46
+ testId,
47
+ }: LanguageSwitcherProps): ReactNode {
48
+ const resolver = useLocale();
49
+
50
+ const activeLocale = resolver.locale();
51
+ // Match entweder exact ("de-DE") oder Language-Root ("de") gegen die
52
+ // verfügbaren Optionen. So zeigt der Switcher "Deutsch" aktiv wenn
53
+ // der Browser "de-AT" liefert und die Option nur "de" heißt.
54
+ const activeOption = useMemo(() => {
55
+ const exact = locales.find((o) => o.code === activeLocale);
56
+ if (exact) return exact;
57
+ const root = activeLocale.split("-")[0];
58
+ return locales.find((o) => o.code === root);
59
+ }, [locales, activeLocale]);
60
+
61
+ if (resolver.setLocale === undefined) {
62
+ // Stateless-Resolver → kein Wechsel möglich. Kein Noise im Topbar.
63
+ return null;
64
+ }
65
+
66
+ const setLocale = resolver.setLocale;
67
+
68
+ return (
69
+ <DropdownMenu>
70
+ <DropdownMenuTrigger asChild>
71
+ <button
72
+ type="button"
73
+ aria-label={label}
74
+ title={label}
75
+ data-testid={testId}
76
+ className={cn(
77
+ "inline-flex h-8 items-center gap-1.5 rounded-md border bg-background px-2 text-sm",
78
+ "hover:bg-accent hover:text-accent-foreground",
79
+ "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
80
+ )}
81
+ >
82
+ <span aria-hidden="true">{icon}</span>
83
+ <span className="uppercase text-xs text-muted-foreground">
84
+ {activeOption?.code ?? activeLocale.slice(0, 2)}
85
+ </span>
86
+ </button>
87
+ </DropdownMenuTrigger>
88
+ <DropdownMenuContent align="end" className="min-w-[10rem]" aria-label={label}>
89
+ {locales.map((opt) => (
90
+ <DropdownMenuCheckboxItem
91
+ key={opt.code}
92
+ checked={opt === activeOption}
93
+ onSelect={() => setLocale(opt.code)}
94
+ >
95
+ <span className="truncate">{opt.label}</span>
96
+ </DropdownMenuCheckboxItem>
97
+ ))}
98
+ </DropdownMenuContent>
99
+ </DropdownMenu>
100
+ );
101
+ }