@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,85 @@
1
+ // Browser-spezifischer LocaleResolver-Default für createKumikoApp.
2
+ //
3
+ // Stateful: hält die aktuelle Locale in localStorage, bricht subscribe-
4
+ // listener bei setLocale(). Initial-wert kommt aus localStorage wenn
5
+ // gespeichert, sonst aus navigator.language, sonst aus defaultLocale.
6
+ //
7
+ // App-Code setzt die Locale programmatisch via resolver.setLocale() —
8
+ // der LanguageSwitcher-Component macht genau das. Persistenz über
9
+ // localStorage heißt: nach Page-Reload gleiche Sprache, ohne Server-
10
+ // Roundtrip. Für device-übergreifende Persistenz würde ein
11
+ // user:write:update auf user.locale zusätzlich gesetzt (separater
12
+ // App-Code, nicht im Resolver).
13
+
14
+ import { createStore, type LocaleResolver } from "@cosmicdrift/kumiko-headless";
15
+
16
+ export type CreateBrowserLocaleResolverOptions = {
17
+ /** localStorage-Key unter dem die aktive Locale persistiert wird.
18
+ * Default: `"kumiko:locale"`. Apps die mehrere Kumiko-Instanzen auf
19
+ * derselben Origin mounten (selten), setzen verschiedene Keys. */
20
+ readonly storageKey?: string;
21
+ /** Fallback wenn weder localStorage noch navigator.language liefern.
22
+ * Default: `"en"`. */
23
+ readonly defaultLocale?: string;
24
+ };
25
+
26
+ function detectInitialLocale(storageKey: string, fallback: string): string {
27
+ if (typeof localStorage !== "undefined") {
28
+ try {
29
+ const stored = localStorage.getItem(storageKey);
30
+ if (stored !== null && stored.length > 0) return stored;
31
+ } catch {
32
+ // localStorage kann throwen (safari private mode, disabled) —
33
+ // leise auf navigator zurückfallen.
34
+ }
35
+ }
36
+ if (typeof navigator !== "undefined" && navigator.language) {
37
+ return navigator.language;
38
+ }
39
+ return fallback;
40
+ }
41
+
42
+ function detectTimeZone(): string {
43
+ if (typeof Intl === "undefined") return "UTC";
44
+ try {
45
+ return Intl.DateTimeFormat().resolvedOptions().timeZone ?? "UTC";
46
+ } catch {
47
+ return "UTC";
48
+ }
49
+ }
50
+
51
+ /** Default-Resolver wenn createKumikoApp ohne `locale`-Option gebootet
52
+ * wird. Stateful: setLocale() persistiert in localStorage und ruft
53
+ * subscribed listeners. Apps die eine vollwertige i18n-Schicht haben
54
+ * (i18next, FormatJS, eigener Store), reichen stattdessen ihre eigene
55
+ * Resolver-Impl via `createKumikoApp({ locale })`. */
56
+ export function createBrowserLocaleResolver(
57
+ options: CreateBrowserLocaleResolverOptions = {},
58
+ ): LocaleResolver {
59
+ const storageKey = options.storageKey ?? "kumiko:locale";
60
+ const fallback = options.defaultLocale ?? "en";
61
+ const store = createStore(detectInitialLocale(storageKey, fallback));
62
+ const timeZone = detectTimeZone();
63
+
64
+ return {
65
+ translate: (key) => key,
66
+ locale: () => store.getSnapshot(),
67
+ timeZone: () => timeZone,
68
+ subscribe: store.subscribe,
69
+ setLocale: (next) => {
70
+ // Early-Return so we don't write localStorage on a no-op. Store's
71
+ // own Object.is-Gate would already block listener notification,
72
+ // but the persistence side-effect lives outside the store.
73
+ if (next === store.getSnapshot()) return;
74
+ store.setState(next);
75
+ if (typeof localStorage !== "undefined") {
76
+ try {
77
+ localStorage.setItem(storageKey, next);
78
+ } catch {
79
+ // Persistenz fehlgeschlagen ist nicht fatal — die Session-Locale
80
+ // bleibt trotzdem gesetzt, nur der nächste Reload verliert sie.
81
+ }
82
+ }
83
+ },
84
+ };
85
+ }
@@ -0,0 +1,63 @@
1
+ // Plugin-Shape für feature-gelieferte Client-Extensions. Server-Features
2
+ // (defineFeature) registrieren Handler, Projections, Hooks; Client-
3
+ // Features registrieren Context-Provider und Gate-Wrapper. createKumikoApp
4
+ // stackt sie in einer definierten Reihenfolge in den React-Tree.
5
+ //
6
+ // Warum die Zweiteilung (providers/gates)? Damit Features, die selbst
7
+ // einen Gate brauchen (z.B. AuthGate → LoginScreen), trotzdem den
8
+ // Context anderer Features anzapfen können: erst werden alle Provider
9
+ // ganz außen gestackt, dann alle Gates nach innen. So hat jeder Gate
10
+ // Zugriff auf jeden Provider, egal welches Feature ihn gebracht hat.
11
+
12
+ import type { ColumnRendererComponent, TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
13
+ import type { ComponentType, ReactNode } from "react";
14
+
15
+ export type ClientFeatureDefinition = {
16
+ readonly name: string;
17
+ /** Context-Provider die um den kompletten Renderer-Tree gewrapped
18
+ * werden. Reihenfolge: erstes Element = äußerster Provider. Alle
19
+ * Provider stehen VOR allen Gates im Baum, damit jeder Gate und
20
+ * jedes Screen-Child darauf Zugriff hat. */
21
+ readonly providers?: readonly ComponentType<{ children: ReactNode }>[];
22
+ /** Screen-Decorators die zwischen dem Provider-Stack und dem Shell/
23
+ * Screen-Render sitzen. Typisches Muster: AuthGate rendert den
24
+ * LoginScreen statt children solange der User nicht eingeloggt ist.
25
+ * Reihenfolge: erstes Element = äußerster Gate. */
26
+ readonly gates?: readonly ComponentType<{ children: ReactNode }>[];
27
+ /** Default-Translations die das Feature für seine UI-Strings
28
+ * mitbringt. Werden in den LocaleProvider als Fallback-Bundle
29
+ * eingehängt — der App-Resolver (z.B. i18next) hat Vorrang und kann
30
+ * einzelne Keys überschreiben, ohne dass die Feature-Bundles
31
+ * komplett ausgetauscht werden müssen. */
32
+ readonly translations?: TranslationsByLocale;
33
+ /** Custom-Screen-Components — Map screenId → React-Component. Wenn
34
+ * ein Schema-Screen `type: "custom"` hat, schaut KumikoScreen in
35
+ * diese Map (gemerged aus allen ClientFeatures) und rendert die
36
+ * passende Component. So muss kein Sample mehr im AppShell-Wrapper
37
+ * selbst routen. Convention: screenId entspricht dem `id` aus
38
+ * `r.screen({ id, type: "custom", ... })` im server-side Feature. */
39
+ readonly components?: Readonly<Record<string, ComponentType>>;
40
+ /** Column-Renderer-Components — Map renderer-name → React-Component.
41
+ * Schema deklariert eine Column mit
42
+ * `renderer: { react: { __component: "ColorSwatch" } }`; client-side
43
+ * zieht der DataTable-Cell-Renderer den Component hier raus.
44
+ * Schemas bleiben so serializable (nur ein String-Key auf der Wire),
45
+ * echte JSX-Renderer leben im Client-Bundle. Last-Wins bei Key-
46
+ * Kollision über mehrere Features. */
47
+ readonly columnRenderers?: Readonly<Record<string, ColumnRendererComponent>>;
48
+ };
49
+
50
+ /** Wickelt einen ReactNode durch eine Liste von Providern/Gates von
51
+ * innen nach außen — erstes Array-Element ist äußerste Hülle.
52
+ * Der Key nutzt den Component-Display-Namen (falls gesetzt) plus
53
+ * Index, damit Mounts stabil bleiben solange die Wrapper-Liste
54
+ * ihre Reihenfolge nicht ändert. */
55
+ export function stackWrappers(
56
+ wrappers: readonly ComponentType<{ children: ReactNode }>[],
57
+ inner: ReactNode,
58
+ ): ReactNode {
59
+ return wrappers.reduceRight<ReactNode>((acc, Wrapper, i) => {
60
+ const key = `${Wrapper.displayName ?? Wrapper.name ?? "wrapper"}-${i}`;
61
+ return <Wrapper key={key}>{acc}</Wrapper>;
62
+ }, inner);
63
+ }
@@ -0,0 +1,380 @@
1
+ import { createLiveDispatcher } from "@cosmicdrift/kumiko-dispatcher-live";
2
+ import type {
3
+ Dispatcher,
4
+ ListRowViewModel,
5
+ LocaleResolver,
6
+ Translate,
7
+ } from "@cosmicdrift/kumiko-headless";
8
+ import {
9
+ type AppSchema,
10
+ type ColumnRendererComponent,
11
+ ColumnRenderersProvider,
12
+ CustomScreensProvider,
13
+ DispatcherProvider,
14
+ type FeatureSchema,
15
+ KumikoScreen,
16
+ kumikoDefaultTranslations,
17
+ LiveEventsProvider,
18
+ LocaleProvider,
19
+ type NavApi,
20
+ NavProvider,
21
+ PrimitivesProvider,
22
+ type PrimitivesRegistry,
23
+ qualifyScreenId,
24
+ TokensProvider,
25
+ toAppSchema,
26
+ useNav,
27
+ } from "@cosmicdrift/kumiko-renderer";
28
+ import { type ComponentType, type ReactNode, useMemo } from "react";
29
+ import { createRoot } from "react-dom/client";
30
+ import { lastSegment } from "../layout/nav-tree";
31
+ import { defaultPrimitives } from "../primitives";
32
+ import { ToastProvider } from "../primitives/toast";
33
+ import { createEventSourceLiveEvents } from "../sse/live-events";
34
+ import { useBrowserTokensApi } from "../tokens";
35
+ import { createBrowserLocaleResolver } from "./browser-locale";
36
+ import { type ClientFeatureDefinition, stackWrappers } from "./client-plugin";
37
+ import { useBrowserNavApi } from "./nav";
38
+
39
+ // Web-Bootstrap. Mounted den ganzen Kumiko-Render-Stack im Browser:
40
+ // Tokens (class-based light/dark via <html>), Primitives (HTML),
41
+ // Navigation (window.history), LiveEvents (EventSource), Dispatcher
42
+ // (live-HTTP). URL ist Source-of-Truth für den aktuellen Screen.
43
+ //
44
+ // Typical client.ts:
45
+ //
46
+ // createKumikoApp({ schema: clientSchema });
47
+
48
+ export type CreateKumikoAppOptions = {
49
+ /** App-Schema. Akzeptiert AppSchema (multi-feature) oder die legacy
50
+ * FeatureSchema (single-feature) — toAppSchema() normalisiert intern.
51
+ *
52
+ * Optional: ohne Argument liest createKumikoApp das schema aus
53
+ * `window.__KUMIKO_SCHEMA__`, das der dev-server beim Boot in die
54
+ * HTML injiziert (siehe @cosmicdrift/kumiko-dev-server: injectSchema).
55
+ * Production-Apps mit eigenem Bundling-Setup können das Global selbst
56
+ * setzen (`<script>window.__KUMIKO_SCHEMA__=...</script>` aus einem
57
+ * build-time bake oder einem fetch). Wer kein Schema übergibt UND
58
+ * keins im Window vorfindet bekommt einen Fehler beim Mount. */
59
+ readonly schema?: AppSchema | FeatureSchema;
60
+ readonly rootId?: string;
61
+ readonly dispatcher?: Dispatcher;
62
+ readonly screenQn?: string;
63
+ readonly translate?: Translate;
64
+ readonly onRowClick?: (row: ListRowViewModel, entityName: string) => void;
65
+ /** App-Shell. Bekommt das resolved `schema` als Prop — so können
66
+ * AppShell-Komponenten an WorkspaceShell/DefaultAppShell durchreichen
67
+ * ohne selbst das Schema zu importieren oder auf window-Globals zu
68
+ * greifen. */
69
+ readonly shell?: (props: {
70
+ readonly children: ReactNode;
71
+ readonly schema: AppSchema;
72
+ }) => ReactNode;
73
+ readonly primitives?: Partial<PrimitivesRegistry>;
74
+ /** Feature-gelieferte Client-Extensions. Jedes Element bringt
75
+ * Provider + Gates mit — siehe ClientFeatureDefinition. Beispiel:
76
+ * `clientFeatures: [emailPasswordClient()]` für Session+Login. */
77
+ readonly clientFeatures?: readonly ClientFeatureDefinition[];
78
+ /** App-level LocaleResolver. Typischerweise ein Adapter um i18next
79
+ * oder eine eigene Store-Impl. Wenn nicht gesetzt → Static-Default
80
+ * (`locale: "en"`, `translate: key → key`); Plugin-Translations
81
+ * springen dann als Fallback ein. */
82
+ readonly locale?: LocaleResolver;
83
+ /** Nav-Adapter — ein React-Hook der eine NavApi-Instanz liefert
84
+ * (route + navigate + hrefFor). Default: `useBrowserNavApi`, das
85
+ * window.history als Source-of-Truth benutzt. Wer einen anderen
86
+ * Router anbinden will (TanStack Router, Expo Linking auf Mobile,
87
+ * Memory-Router in Tests) übergibt hier seinen eigenen Hook.
88
+ *
89
+ * Der Hook wird EINMAL im Component-Tree aufgerufen (siehe
90
+ * `BrowserNavBoot`), muss also den React-Rules-of-Hooks folgen —
91
+ * `useSyncExternalStore` auf der zugrundeliegenden Router-State
92
+ * ist das gängige Pattern.
93
+ *
94
+ * Wenn das Schema Workspaces deklariert, wird der Adapter mit
95
+ * `{ hasWorkspaces: true }` aufgerufen — der Default-Hook nutzt das
96
+ * um das URL-Pattern auf `/<workspace>/<screen>[/<entityId>]`
97
+ * umzustellen. Eigene Adapter dürfen die Option ignorieren wenn ihr
98
+ * Router das anders löst. */
99
+ readonly navAdapter?: (options?: { readonly hasWorkspaces?: boolean }) => NavApi;
100
+ };
101
+
102
+ // Reads the dev-server-injected schema from the global. Guarded for
103
+ // SSR/node — die Funktion läuft heute nur im Browser, aber das schadet
104
+ // auch unter jsdom nicht.
105
+ function readInjectedSchema(): AppSchema | FeatureSchema | undefined {
106
+ if (typeof window === "undefined") return undefined;
107
+ const w = window as unknown as { __KUMIKO_SCHEMA__?: AppSchema | FeatureSchema };
108
+ return w.__KUMIKO_SCHEMA__;
109
+ }
110
+
111
+ export function createKumikoApp(options: CreateKumikoAppOptions = {}): void {
112
+ const rootId = options.rootId ?? "root";
113
+ const container = document.getElementById(rootId);
114
+ if (!container) {
115
+ throw new Error(
116
+ `createKumikoApp: DOM element #${rootId} not found. Make sure your HTML has a matching <div id="${rootId}"></div> before the bundle loads.`,
117
+ );
118
+ }
119
+
120
+ // Resolve das Schema. Reihenfolge:
121
+ // 1. options.schema explizit übergeben → nutzen
122
+ // 2. window.__KUMIKO_SCHEMA__ (vom dev-server injiziert) → nutzen
123
+ // 3. Sonst → throw mit klarer Anleitung was zu tun ist
124
+ // toAppSchema normalisiert die FeatureSchema/AppSchema-Union, ab hier
125
+ // kennen alle Layouts nur noch AppSchema.
126
+ const rawSchema = options.schema ?? readInjectedSchema();
127
+ if (rawSchema === undefined) {
128
+ throw new Error(
129
+ "createKumikoApp: kein Schema übergeben und window.__KUMIKO_SCHEMA__ nicht gesetzt. " +
130
+ "Entweder `schema: <FeatureSchema|AppSchema>` an createKumikoApp übergeben, oder " +
131
+ "den dev-server (@cosmicdrift/kumiko-dev-server) nutzen — der injiziert das Schema beim Boot.",
132
+ );
133
+ }
134
+ const app = toAppSchema(rawSchema);
135
+
136
+ // Fallback-Screen: erstes Screen über alle Features in deklarierter
137
+ // Reihenfolge. War vorher schema.screens[0], jetzt search the first
138
+ // feature with screens.
139
+ const firstFeatureWithScreens = app.features.find((f) => f.screens.length > 0);
140
+ const firstScreen = firstFeatureWithScreens?.screens[0];
141
+ const fallbackQn =
142
+ options.screenQn ??
143
+ (firstScreen !== undefined && firstFeatureWithScreens !== undefined
144
+ ? qualifyScreenId(firstFeatureWithScreens.featureName, firstScreen.id)
145
+ : undefined);
146
+ if (!fallbackQn) {
147
+ throw new Error(
148
+ "createKumikoApp: schema contains no screens. Add at least one entry to `schema.screens` or pass `screenQn` explicitly.",
149
+ );
150
+ }
151
+
152
+ const dispatcher = options.dispatcher ?? createLiveDispatcher();
153
+ const primitives: PrimitivesRegistry = { ...defaultPrimitives, ...(options.primitives ?? {}) };
154
+ const liveEvents = createEventSourceLiveEvents();
155
+
156
+ // Feature-Plugins: providers stacken außen (jeder Gate + Screen sieht
157
+ // jeden Provider), gates stacken zwischen Renderer-Providern und
158
+ // Shell/Screen. Array-Order: erstes Element = äußerste Hülle.
159
+ const clientFeatures = options.clientFeatures ?? [];
160
+ const providers = clientFeatures.flatMap((f) => f.providers ?? []);
161
+ const gates = clientFeatures.flatMap((f) => f.gates ?? []);
162
+ // Framework-Default-Bundle als ALLERLETZTER Fallback — App-Resolver +
163
+ // clientFeatures.translations haben Vorrang. Apps können einzelne
164
+ // kumiko.*-Keys via clientFeatures.translations überschreiben (z.B.
165
+ // "kumiko.actions.save" → "Sichern" für ein bestimmtes Feature).
166
+ const fallbackBundles = [
167
+ ...clientFeatures.flatMap((f) => (f.translations !== undefined ? [f.translations] : [])),
168
+ kumikoDefaultTranslations,
169
+ ];
170
+ // Custom-Screen-Components-Map mergen: spätere Features überschreiben
171
+ // frühere bei screenId-Kollision (Last-Wins). Apps können so ein
172
+ // bundled-Feature mit lokaler Override versehen.
173
+ const customScreens: Record<string, ComponentType> = {};
174
+ for (const f of clientFeatures) {
175
+ if (f.components !== undefined) Object.assign(customScreens, f.components);
176
+ }
177
+ // Column-Renderer-Map mergen — gleiche Last-Wins-Semantik wie bei
178
+ // customScreens. Doppelte Keys über Features sind selten gewollt;
179
+ // wir warnen einmalig pro Kollision damit das nicht stillschweigend
180
+ // den Renderer einer Library überschreibt.
181
+ const columnRenderers: Record<string, ColumnRendererComponent> = {};
182
+ for (const f of clientFeatures) {
183
+ if (f.columnRenderers === undefined) continue;
184
+ for (const [key, value] of Object.entries(f.columnRenderers)) {
185
+ if (columnRenderers[key] !== undefined) {
186
+ // biome-ignore lint/suspicious/noConsole: dev-warning für Schema-Konflikte
187
+ console.warn(
188
+ `[kumiko] columnRenderer "${key}" defined by multiple clientFeatures — last definition (from "${f.name}") wins.`,
189
+ );
190
+ }
191
+ columnRenderers[key] = value;
192
+ }
193
+ }
194
+
195
+ const resolver = options.locale ?? createBrowserLocaleResolver();
196
+
197
+ const navAdapter = options.navAdapter ?? useBrowserNavApi;
198
+ const hasWorkspaces = (app.workspaces?.length ?? 0) > 0;
199
+ const screenNode = (
200
+ <BrowserNavBoot
201
+ app={app}
202
+ fallbackQn={fallbackQn}
203
+ useNavApi={navAdapter}
204
+ hasWorkspaces={hasWorkspaces}
205
+ {...(options.translate !== undefined && { translate: options.translate })}
206
+ {...(options.onRowClick !== undefined && { onRowClick: options.onRowClick })}
207
+ {...(options.shell !== undefined && { shell: options.shell })}
208
+ />
209
+ );
210
+
211
+ const tree = (
212
+ <TokensBoot>
213
+ <LocaleProvider resolver={resolver} fallbackBundles={fallbackBundles}>
214
+ <PrimitivesProvider value={primitives}>
215
+ <DispatcherProvider dispatcher={dispatcher}>
216
+ <LiveEventsProvider value={liveEvents}>
217
+ <CustomScreensProvider value={customScreens}>
218
+ <ColumnRenderersProvider value={columnRenderers}>
219
+ <ToastProvider>
220
+ {stackWrappers(providers, stackWrappers(gates, screenNode))}
221
+ </ToastProvider>
222
+ </ColumnRenderersProvider>
223
+ </CustomScreensProvider>
224
+ </LiveEventsProvider>
225
+ </DispatcherProvider>
226
+ </PrimitivesProvider>
227
+ </LocaleProvider>
228
+ </TokensBoot>
229
+ );
230
+
231
+ createRoot(container).render(tree);
232
+ }
233
+
234
+ // TokensBoot nutzt den browser-backed TokensApi-Hook (class-based
235
+ // dark-toggle) und reicht den Wert an den shared TokensProvider
236
+ // durch. Keine eigene State-Haltung — die class auf <html> ist die
237
+ // SSoT, useSyncExternalStore im Hook synced das in React.
238
+ function TokensBoot({ children }: { readonly children: ReactNode }): ReactNode {
239
+ const api = useBrowserTokensApi();
240
+ return <TokensProvider value={api}>{children}</TokensProvider>;
241
+ }
242
+
243
+ function BrowserNavBoot({
244
+ app,
245
+ fallbackQn,
246
+ useNavApi,
247
+ hasWorkspaces,
248
+ translate,
249
+ onRowClick,
250
+ shell,
251
+ }: {
252
+ readonly app: AppSchema;
253
+ readonly fallbackQn: string;
254
+ readonly useNavApi: (options?: { readonly hasWorkspaces?: boolean }) => NavApi;
255
+ readonly hasWorkspaces: boolean;
256
+ readonly translate?: Translate;
257
+ readonly onRowClick?: (row: ListRowViewModel, entityName: string) => void;
258
+ readonly shell?: (props: {
259
+ readonly children: ReactNode;
260
+ readonly schema: AppSchema;
261
+ }) => ReactNode;
262
+ }): ReactNode {
263
+ const navApi = useNavApi({ hasWorkspaces });
264
+ const Shell = shell;
265
+ const screen = (
266
+ <RoutedScreen
267
+ app={app}
268
+ fallbackQn={fallbackQn}
269
+ {...(translate !== undefined && { translate })}
270
+ {...(onRowClick !== undefined && { onRowClick })}
271
+ />
272
+ );
273
+ return (
274
+ <NavProvider value={navApi}>
275
+ {Shell !== undefined ? <Shell schema={app}>{screen}</Shell> : screen}
276
+ </NavProvider>
277
+ );
278
+ }
279
+
280
+ // Sucht das Feature, dem ein vollständig qualifizierter ScreenQn gehört.
281
+ // Returns undefined wenn der Screen in keinem Feature-Schema deklariert
282
+ // ist — KumikoScreen rendert dann den "Screen not found"-Banner.
283
+ function findOwnerFeature(app: AppSchema, qn: string): FeatureSchema | undefined {
284
+ for (const feature of app.features) {
285
+ for (const s of feature.screens) {
286
+ if (qualifyScreenId(feature.featureName, s.id) === qn) return feature;
287
+ }
288
+ }
289
+ return undefined;
290
+ }
291
+
292
+ function RoutedScreen({
293
+ app,
294
+ fallbackQn,
295
+ translate,
296
+ onRowClick,
297
+ }: {
298
+ readonly app: AppSchema;
299
+ readonly fallbackQn: string;
300
+ readonly translate?: Translate;
301
+ readonly onRowClick?: (row: ListRowViewModel, entityName: string) => void;
302
+ }): ReactNode {
303
+ const nav = useNav();
304
+
305
+ // ScreenId aus dem Route ist NICHT qualified — nav.route.screenId
306
+ // kommt aus dem URL-Path und ist die kurze Form ("order-list"). Wir
307
+ // müssen das ans richtige Feature heften. Strategie: durch alle
308
+ // Features iterieren bis das passende Screen-Decl auftaucht. Ohne
309
+ // Match → Fallback-Feature (das vom fallbackQn).
310
+ const { feature, qn, entityId } = useMemo(() => {
311
+ if (nav.route === undefined) {
312
+ return {
313
+ feature: findOwnerFeature(app, fallbackQn),
314
+ qn: fallbackQn,
315
+ entityId: undefined as string | undefined,
316
+ };
317
+ }
318
+ const shortId = nav.route.screenId;
319
+ // Suche das Feature dessen Screens den short id enthalten.
320
+ let matchedFeature: FeatureSchema | undefined;
321
+ for (const f of app.features) {
322
+ if (f.screens.some((s) => s.id === shortId)) {
323
+ matchedFeature = f;
324
+ break;
325
+ }
326
+ }
327
+ const ownerFeature = matchedFeature ?? findOwnerFeature(app, fallbackQn);
328
+ const qualifiedQn = ownerFeature ? qualifyScreenId(ownerFeature.featureName, shortId) : shortId;
329
+ return {
330
+ feature: ownerFeature,
331
+ qn: qualifiedQn,
332
+ entityId: nav.route.entityId,
333
+ };
334
+ }, [nav.route, app, fallbackQn]);
335
+
336
+ const effectiveOnRowClick = useMemo<
337
+ ((row: ListRowViewModel, entityName: string) => void) | undefined
338
+ >(() => {
339
+ if (onRowClick !== undefined) return onRowClick;
340
+ return (row, entityName) => {
341
+ // Edit-Screen für die Entity über alle Features suchen — im
342
+ // Single-Feature-Setup ist das das gleiche Feature wie das aktive,
343
+ // im Multi-Feature kann der Edit theoretisch in einem anderen
344
+ // Feature liegen (eines, das die Entity teilt).
345
+ for (const f of app.features) {
346
+ const editScreen = f.screens.find(
347
+ (s) => s.type === "entityEdit" && s.entity === entityName,
348
+ );
349
+ if (editScreen) {
350
+ // editScreen.id ist QN-Form (registry-stamped); nav.navigate
351
+ // erwartet Short-Form. Sonst wird die URL doppelt-qualifiziert.
352
+ nav.navigate({ screenId: lastSegment(editScreen.id), entityId: row.id });
353
+ return;
354
+ }
355
+ }
356
+ };
357
+ }, [onRowClick, app.features, nav]);
358
+
359
+ // KumikoScreen will nach wie vor ein single-feature schema. Wir
360
+ // füttern es mit dem owning Feature — es enthält Entity-Defs +
361
+ // Screen-Defs für den aktiven Render-Pfad. Kein Owner gefunden → wir
362
+ // nutzen das erste Feature als Fallback (KumikoScreen zeigt dann den
363
+ // "Screen not found"-Banner für das nicht-existente qn).
364
+ const schemaForScreen: FeatureSchema = feature ??
365
+ app.features[0] ?? {
366
+ featureName: "",
367
+ entities: {},
368
+ screens: [],
369
+ };
370
+
371
+ return (
372
+ <KumikoScreen
373
+ schema={schemaForScreen}
374
+ qn={qn}
375
+ {...(translate !== undefined && { translate })}
376
+ {...(entityId !== undefined && { entityId })}
377
+ onRowClick={effectiveOnRowClick}
378
+ />
379
+ );
380
+ }