@cosmicdrift/kumiko-renderer 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.
@@ -0,0 +1,528 @@
1
+ // Primitives-Contract. Plattform-neutral — die Types beschreiben die
2
+ // semantische Oberfläche, die konkrete Implementation (HTML, React
3
+ // Native) kommt aus `@cosmicdrift/kumiko-renderer-web` oder `@cosmicdrift/kumiko-renderer-native`.
4
+ //
5
+ // Die Renderer (RenderEdit, RenderList, RenderField, KumikoScreen)
6
+ // konsumieren nur diesen Context. Es gibt KEIN Default-Registry hier
7
+ // — jede App muss eine vollständige Registry stellen; die Plattform-
8
+ // Packages liefern defaultPrimitives mit, die via createKumikoApp
9
+ // durchgereicht werden.
10
+ //
11
+ // Core-Primitives (Kumikos eigene Components konsumieren sie):
12
+ //
13
+ // Button — submit/button, disabled, onClick, variant
14
+ // Banner — role=alert Box für Fehler/Info, optional Actions
15
+ // Field — label + issues um ein Input-Control
16
+ // Input — discriminated union über text/number/boolean/date
17
+ // DataTable — Spalten + Zeilen + onRowClick, Empty-State intern
18
+ // Form — submit-Wrapper (Web: <form>, Native: View + onSubmit)
19
+ // Section — titled Gruppe von Feldern (Web: <fieldset>+<legend>)
20
+ // Grid — columns-basiertes Layout innerhalb einer Section
21
+ // Text — semantische Text-Variante (body/small/code/required-mark)
22
+ //
23
+ // App-Primitives (der App-Dev füttert eigene):
24
+ //
25
+ // Das `AppPrimitives`-Interface ist leer — Devs erweitern es via
26
+ // Module-Augmentation mit ihren eigenen Components. Die wandern
27
+ // automatisch in `PrimitivesRegistry` und `usePrimitives()` liefert
28
+ // sie typed aus. Kumikos Components nutzen sie NICHT (und kennen sie
29
+ // nicht), aber der App-Code hat ein einheitliches Primitive-Vokabular
30
+ // über Core + Custom.
31
+
32
+ import type {
33
+ FieldIssue,
34
+ ListColumnViewModel,
35
+ ListRowViewModel,
36
+ } from "@cosmicdrift/kumiko-headless";
37
+ import {
38
+ type ComponentType,
39
+ createContext,
40
+ type FormEvent,
41
+ type ReactNode,
42
+ useContext,
43
+ } from "react";
44
+
45
+ // ---- Prop-Types (die Primitive-Contract-Oberfläche) ----
46
+
47
+ /** Standard-Button. `loading` zeigt einen Spinner statt der Children
48
+ * und sollte mit `disabled` kombiniert werden, wenn die Action wirklich
49
+ * blockiert bis das Loading durch ist (z.B. async submit). Native-
50
+ * Impls können den Spinner als Activity-Indicator rendern. */
51
+ export type ButtonProps = {
52
+ readonly type?: "button" | "submit";
53
+ readonly onClick?: () => void | Promise<void>;
54
+ readonly disabled?: boolean;
55
+ /** Spinner statt Children rendern. Caller sollte `disabled` mit-
56
+ * setzen wenn die Action blockiert bis das Loading abgeschlossen
57
+ * ist (verhindert Double-Submit). */
58
+ readonly loading?: boolean;
59
+ /** Semantische Klasse — default="primary". Custom-Impls entscheiden
60
+ * was daraus visuell wird; die Renderer verwenden "primary" für
61
+ * Save, "danger" für Delete, "secondary" für Confirm-State. */
62
+ readonly variant?: "primary" | "secondary" | "danger";
63
+ readonly children: ReactNode;
64
+ readonly testId?: string;
65
+ };
66
+
67
+ /** Banner für inline-Message ODER Page-State (z.B. "Loading…",
68
+ * "Screen not found"). `padded` setzt einen Außenabstand damit der
69
+ * Banner nicht edge-to-edge an den Main-Border klebt — relevant
70
+ * seit `<main>` kein eigenes Padding mehr hat. */
71
+ export type BannerProps = {
72
+ /** "error" für Alerts (Konflikt, Netzfehler), "info" für neutrale
73
+ * Platzhalter (Not-Found, Loading), "loading" für Lade-States. */
74
+ readonly variant?: "error" | "info" | "loading";
75
+ readonly children: ReactNode;
76
+ /** Optional — weitere Knöpfe/Elemente rechts vom Text (z.B. "Neu
77
+ * laden"). Inline, nicht als eigener Block. */
78
+ readonly actions?: ReactNode;
79
+ /** Setzt einen Außenabstand um den Banner — für Page-States wo der
80
+ * Banner alleine im Main rendert (KumikoScreen "not-found",
81
+ * "loading", etc.). Web fügt p-6 als Margin um den Banner. */
82
+ readonly padded?: boolean;
83
+ readonly testId?: string;
84
+ };
85
+
86
+ export type FieldProps = {
87
+ readonly id: string;
88
+ readonly label: string;
89
+ readonly required?: boolean;
90
+ readonly issues?: readonly FieldIssue[];
91
+ readonly children: ReactNode;
92
+ readonly testId?: string;
93
+ };
94
+
95
+ /** Discriminated union — jede Input-Sorte hat ihre eigene value/onChange
96
+ * Signatur. Custom-Impls dispatchen intern, rendern anders (Toggle
97
+ * statt Checkbox), oder nur einzelne kinds unterschiedlich. */
98
+ export type InputProps =
99
+ | {
100
+ readonly kind: "text";
101
+ readonly id: string;
102
+ readonly name: string;
103
+ readonly value: string;
104
+ readonly onChange: (v: string) => void;
105
+ readonly disabled?: boolean;
106
+ readonly required?: boolean;
107
+ readonly hasError?: boolean;
108
+ /** Hint-Text wenn das Feld leer ist. Used für Search-Inputs in
109
+ * Toolbars ("Suchen…") wo kein Label sinnvoll ist. */
110
+ readonly placeholder?: string;
111
+ /** Browser-Autofill / Native-Keyboard-Hint. Web setzt das auf
112
+ * `<input autocomplete=...>`, Native auf `textContentType`. */
113
+ readonly autoComplete?: string;
114
+ }
115
+ | {
116
+ readonly kind: "email";
117
+ readonly id: string;
118
+ readonly name: string;
119
+ readonly value: string;
120
+ readonly onChange: (v: string) => void;
121
+ readonly disabled?: boolean;
122
+ readonly required?: boolean;
123
+ readonly hasError?: boolean;
124
+ readonly placeholder?: string;
125
+ /** Default "email". Apps die "username" wollen (Login-Form mit
126
+ * Username-or-Email) reichen das durch. */
127
+ readonly autoComplete?: string;
128
+ }
129
+ | {
130
+ readonly kind: "password";
131
+ readonly id: string;
132
+ readonly name: string;
133
+ readonly value: string;
134
+ readonly onChange: (v: string) => void;
135
+ readonly disabled?: boolean;
136
+ readonly required?: boolean;
137
+ readonly hasError?: boolean;
138
+ /** "current-password" für Login, "new-password" für Reset/Signup —
139
+ * Browser-Password-Manager nutzen das für die Speicherentscheidung.
140
+ * Native: textContentType="password" / "newPassword". */
141
+ readonly autoComplete?: "current-password" | "new-password";
142
+ }
143
+ | {
144
+ readonly kind: "number";
145
+ readonly id: string;
146
+ readonly name: string;
147
+ readonly value: number | "";
148
+ readonly onChange: (v: number | undefined) => void;
149
+ readonly disabled?: boolean;
150
+ readonly required?: boolean;
151
+ readonly hasError?: boolean;
152
+ }
153
+ | {
154
+ readonly kind: "boolean";
155
+ readonly id: string;
156
+ readonly name: string;
157
+ readonly value: boolean;
158
+ readonly onChange: (v: boolean) => void;
159
+ readonly disabled?: boolean;
160
+ readonly required?: boolean;
161
+ readonly hasError?: boolean;
162
+ }
163
+ | {
164
+ readonly kind: "date";
165
+ readonly id: string;
166
+ readonly name: string;
167
+ readonly value: string;
168
+ readonly onChange: (v: string | undefined) => void;
169
+ /** Locale für die Datum-Formatierung im Trigger. Default = Browser-
170
+ * Locale via navigator.language. Apps mit eigenem LocaleResolver
171
+ * können ihren current locale durchreichen. */
172
+ readonly locale?: string;
173
+ readonly disabled?: boolean;
174
+ readonly required?: boolean;
175
+ readonly hasError?: boolean;
176
+ }
177
+ | {
178
+ readonly kind: "select";
179
+ readonly id: string;
180
+ readonly name: string;
181
+ readonly value: string;
182
+ readonly onChange: (v: string) => void;
183
+ /** Erlaubte Werte. Leeres Array → Dropdown ohne Optionen, ein
184
+ * required Field ist dann nicht erfüllbar (Author-Hinweis, nicht
185
+ * Endnutzer). String-Form für statische Listen (Wert == Label),
186
+ * {value,label}-Form für DB-getragene Refs (Tier 2.7e-3). */
187
+ readonly options:
188
+ | readonly string[]
189
+ | readonly { readonly value: string; readonly label: string }[];
190
+ readonly disabled?: boolean;
191
+ readonly required?: boolean;
192
+ readonly hasError?: boolean;
193
+ }
194
+ | ({
195
+ // Tier 2.1c: Combobox / Searchable-Select. Single-Mode (multiple
196
+ // false oder weggelassen): value ist string, onChange string.
197
+ // Multi-Mode (multiple: true): value ist string[], onChange
198
+ // string[]. Reference-Felder (Tier 2.7e-3+) nutzen das hier ohne
199
+ // explizit Combobox zu setzen — der Renderer wählt Combobox
200
+ // automatisch für reference-Field-Types.
201
+ // Discriminated-Union per `multiple`: Single-Mode hat string-
202
+ // value/onChange, Multi-Mode hat readonly string[] — Caller
203
+ // muss den Mode beim Build wählen, der Compiler zwingt die
204
+ // richtige Signatur.
205
+ readonly kind: "combobox";
206
+ readonly id: string;
207
+ readonly name: string;
208
+ readonly options: readonly { readonly value: string; readonly label: string }[];
209
+ readonly disabled?: boolean;
210
+ readonly required?: boolean;
211
+ readonly hasError?: boolean;
212
+ readonly placeholder?: string;
213
+ readonly searchPlaceholder?: string;
214
+ readonly emptyText?: string;
215
+ /** Tier 2.7e Remote-Search: gesetzt = Combobox läuft im Remote-
216
+ * Mode (cmdk-Local-Filter aus, Search-Input ruft onSearchChange
217
+ * debounced an den Caller). */
218
+ readonly onSearchChange?: (q: string) => void;
219
+ readonly loading?: boolean;
220
+ } & (
221
+ | {
222
+ readonly multiple?: false;
223
+ readonly value: string;
224
+ readonly onChange: (v: string) => void;
225
+ }
226
+ | {
227
+ readonly multiple: true;
228
+ readonly value: readonly string[];
229
+ readonly onChange: (v: readonly string[]) => void;
230
+ }
231
+ ))
232
+ | {
233
+ readonly kind: "money";
234
+ readonly id: string;
235
+ readonly name: string;
236
+ /** Internal-Format: Cents/Minor-Units als Integer (z.B. 1299 für
237
+ * 12,99 EUR). Renderer übersetzt fürs UI in Major-Units mit
238
+ * Locale-formatierten Dezimalstellen. Empty-State = `""`. */
239
+ readonly value: number | "";
240
+ readonly onChange: (v: number | undefined) => void;
241
+ /** ISO-4217 Currency-Code, z.B. "EUR" / "USD" / "CHF". Default
242
+ * "EUR". Renderer zeigt das Symbol als Suffix und formatiert die
243
+ * Decimal-Stellen entsprechend (EUR/USD/CHF haben 2, JPY hat 0). */
244
+ readonly currency?: string;
245
+ /** Locale für Zahlen-Formatierung. Default "de-DE" (Komma als
246
+ * Dezimaltrenner). Apps mit eigenem LocaleResolver können ihren
247
+ * current locale durchreichen. */
248
+ readonly locale?: string;
249
+ readonly disabled?: boolean;
250
+ readonly required?: boolean;
251
+ readonly hasError?: boolean;
252
+ }
253
+ | {
254
+ readonly kind: "timestamp";
255
+ readonly id: string;
256
+ readonly name: string;
257
+ /** ISO-8601 Datetime-String inkl. Zeit ("2026-04-25T13:45").
258
+ * Empty-State = `""`. Web nutzt `<input type="datetime-local">`. */
259
+ readonly value: string;
260
+ readonly onChange: (v: string | undefined) => void;
261
+ readonly disabled?: boolean;
262
+ readonly required?: boolean;
263
+ readonly hasError?: boolean;
264
+ }
265
+ | {
266
+ readonly kind: "textarea";
267
+ readonly id: string;
268
+ readonly name: string;
269
+ readonly value: string;
270
+ readonly onChange: (v: string) => void;
271
+ /** Anzahl sichtbarer Zeilen. Default 4 in der Default-Primitive
272
+ * — hinreichend für Notes, vertikal-scrollbar drüber. */
273
+ readonly rows?: number;
274
+ readonly disabled?: boolean;
275
+ readonly required?: boolean;
276
+ readonly hasError?: boolean;
277
+ };
278
+
279
+ // Sort-Wire-Format. `null`-State unterscheidet "User hat noch nichts
280
+ // gesortiert" vom "ich sortiere nach X" — wichtig für 3-State-Toggle
281
+ // (asc → desc → null → asc …). Renderer kennt den aktuellen sort und
282
+ // callt onSortChange wenn der User auf einen sortable-Header klickt.
283
+ export type DataTableSortDir = "asc" | "desc";
284
+ export type DataTableSort = {
285
+ readonly field: string;
286
+ readonly dir: DataTableSortDir;
287
+ };
288
+
289
+ // Resolved-Form einer Row-Action (KumikoScreen baut das aus
290
+ // EntityListScreenDefinition.rowActions): Labels schon translated,
291
+ // handler-QN aufgelöst zu einer onTrigger-Function die den dispatcher
292
+ // kennt. DataTable rendert das ohne weiteres i18n/dispatcher-Wissen.
293
+ export type DataTableRowAction = {
294
+ /** Stable id für aria-labels und data-testids. */
295
+ readonly id: string;
296
+ /** Translated Label. */
297
+ readonly label: string;
298
+ /** Visual-Style — danger triggert in der Default-Primitive eine rote
299
+ * Variante UND erzwingt einen Confirm-Dialog wenn keiner gesetzt ist. */
300
+ readonly style?: "primary" | "secondary" | "danger";
301
+ /** Translated Confirm-Prompt (Description im Dialog) — wenn gesetzt,
302
+ * öffnet ein Modal vor der Ausführung. Bei style=danger ohne expliziten
303
+ * confirm sollte der Renderer einen generischen Default zeigen. */
304
+ readonly confirm?: string;
305
+ /** Translated Confirm-Button-Label im Dialog. Default = `label`
306
+ * (Action-Label wird wiederverwendet). */
307
+ readonly confirmLabel?: string;
308
+ /** Wird mit der ListRowViewModel der geklickten Row aufgerufen. Async
309
+ * erlaubt — der Renderer kann während der Promise-Resolution einen
310
+ * Loading-State auf dem Button zeigen. */
311
+ readonly onTrigger: (row: ListRowViewModel) => Promise<void> | void;
312
+ /** Conditional Visibility pro Row (z.B. "Start" nur wenn status==="scheduled"). */
313
+ readonly isVisible?: (row: ListRowViewModel) => boolean;
314
+ };
315
+
316
+ export type DataTableProps = {
317
+ readonly columns: readonly ListColumnViewModel[];
318
+ readonly rows: readonly ListRowViewModel[];
319
+ readonly onRowClick?: (row: ListRowViewModel) => void;
320
+ /** Aktuelle Sortierung (oder null = unsorted). Wenn columns ein
321
+ * `sortable: true`-Feld haben und onSortChange gesetzt ist, rendert
322
+ * der Renderer Click-Header mit Asc/Desc-Indikator. Ohne onSortChange
323
+ * bleibt die Header-Click-Mechanik aus, columns.sortable ist dann nur
324
+ * semantischer Hinweis. */
325
+ readonly sort?: DataTableSort | null;
326
+ /** Wird gerufen mit dem nächsten Sort-State nach einem Header-Klick.
327
+ * 3-State-Toggle (Convention): asc → desc → null. Caller setzt damit
328
+ * seinen URL-State / Query-Param und triggert ein refetch. */
329
+ readonly onSortChange?: (next: DataTableSort | null) => void;
330
+ /** Pro-Row-Aktionen — eine Spalte am rechten Rand mit Inline-Buttons
331
+ * (≤2 Aktionen) oder Kebab-Dropdown (>2). Caller liefert Resolved-
332
+ * Form (Labels + onTrigger schon verdrahtet); DataTable kümmert
333
+ * sich nur um Render + Confirm-Dialog. */
334
+ readonly rowActions?: readonly DataTableRowAction[];
335
+ /** Custom Empty-State-Inhalt (z. B. Icon + Heading + CTA-Button).
336
+ * Default-Renderer rahmt ihn in einer dashed-border Box. */
337
+ readonly emptyState?: ReactNode;
338
+ /** Optionaler Titel-Slot ganz links der Toolbar — Screen-Titel
339
+ * ("Items", "Bestellungen"). Web rendert als font-medium Heading. */
340
+ readonly toolbarTitle?: ReactNode;
341
+ /** Toolbar-Slot mittig (typisch Search-Input). Renderer entscheidet
342
+ * das Layout — Web spreizt das Element als flex-1 mit max-Breite,
343
+ * Native könnte es als Header-Suchleiste rendern. */
344
+ readonly toolbarStart?: ReactNode;
345
+ /** Toolbar-Slot rechts (typisch + Neu Button, Filter, View-Switch).
346
+ * Web zieht den Cluster mit ml-auto an die rechte Kante. */
347
+ readonly toolbarEnd?: ReactNode;
348
+ /** Pagination-State + Callback. Wenn gesetzt, rendert der Renderer
349
+ * einen Pager unter der Tabelle (Web: Footer-Bar mit ← 1 ... N →).
350
+ * total/limit/page sind 1-basiert für die UI; Server-Translation
351
+ * zu offset = (page-1)*limit liegt beim Caller. Wenn page > total/limit
352
+ * würde, soll der Caller das vorab clampen. */
353
+ readonly pager?: {
354
+ readonly page: number;
355
+ readonly limit: number;
356
+ readonly total: number;
357
+ readonly onPageChange: (next: number) => void;
358
+ };
359
+ /** Infinite-Scroll Callback. Wenn gesetzt, rendert der Renderer einen
360
+ * Bottom-Sentinel und ruft `onReachEnd` wenn der ins Viewport rückt
361
+ * (Web: IntersectionObserver). Caller verwaltet accumulation +
362
+ * cursor + hasMore. `loadingMore=true` zeigt einen Spinner unter
363
+ * der Tabelle solange die nächste Page lädt. Inkompatibel mit
364
+ * `pager` — entweder Pager ODER Infinite-Scroll, nicht beides. */
365
+ readonly onReachEnd?: () => void;
366
+ readonly loadingMore?: boolean;
367
+ /** Wenn false und onReachEnd gesetzt, rendert der Renderer einen
368
+ * "Ende der Liste"-Hinweis statt des Sentinels. Default true. */
369
+ readonly hasMore?: boolean;
370
+ readonly testId?: string;
371
+ };
372
+
373
+ /** Submit-Wrapper. Web: `<form onSubmit>`, Native: View das einen
374
+ * onSubmit-Callback via Button-Press triggert. `onSubmit` bekommt
375
+ * eine abstrakte Signatur (keine FormEvent) damit Native-Impls das
376
+ * sinnvoll füllen können.
377
+ *
378
+ * `title`: linker Slot der sticky-top Bar — typisch der Screen-Titel
379
+ * ("Neuer Eintrag", "Bestellung bearbeiten"). Wenn gesetzt, rendert
380
+ * die Bar mit `justify-between` (Title links, Actions rechts).
381
+ * `actions`: optionaler Slot für die primären Form-Aktionen (Save,
382
+ * Cancel). Web rendert die Bar sticky-top, damit der Save-Button
383
+ * bei langen Forms beim Scrollen erreichbar bleibt. Native-Impls
384
+ * dürfen denselben Slot z. B. als Bottom-Bar rendern. */
385
+ export type FormProps = {
386
+ readonly onSubmit: (e?: FormEvent) => void;
387
+ readonly children: ReactNode;
388
+ readonly title?: ReactNode;
389
+ readonly actions?: ReactNode;
390
+ readonly testId?: string;
391
+ };
392
+
393
+ /** Titled Gruppe von Feldern. Web: `<fieldset>` + `<legend>`, Native:
394
+ * View mit Header-Text. Native-Impls können den Title als Accordion
395
+ * oder Collapsible rendern. */
396
+ export type SectionProps = {
397
+ readonly title: string;
398
+ readonly children: ReactNode;
399
+ readonly testId?: string;
400
+ };
401
+
402
+ /** Columns-basiertes Layout. Web: CSS grid, Native: Flex-Wrap mit
403
+ * Width-%, oder react-native-grid. Jedes direkte Child kann eine
404
+ * `GridCell`-Wrapping bekommen für span-Kontrolle. */
405
+ export type GridProps = {
406
+ readonly columns: number;
407
+ readonly children: ReactNode;
408
+ readonly testId?: string;
409
+ };
410
+
411
+ /** Span-Wrapper für ein Kind innerhalb eines Grid. Web: `style={{gridColumn: span N}}`,
412
+ * Native: eigenes Width-Rechnen. */
413
+ export type GridCellProps = {
414
+ readonly span?: number;
415
+ readonly children: ReactNode;
416
+ };
417
+
418
+ /** Semantischer Text. Variants bilden Standard-Typografie-Rollen ab —
419
+ * `body` ist Default, `small` für sekundäre Labels, `code` für inline
420
+ * monospace (entityId, screen-id), `required-mark` für das Sternchen
421
+ * hinter Labels. Custom-Impls mappen auf ihren TypeScale. */
422
+ export type TextProps = {
423
+ readonly variant?: "body" | "small" | "code" | "required-mark";
424
+ readonly children: ReactNode;
425
+ readonly testId?: string;
426
+ };
427
+
428
+ /** Heading mit zwei Rollen — `page` als Page-Titel (Web: h1), `section`
429
+ * als Sub-Header über einer Group (Web: h2 mit uppercase + muted).
430
+ * Native-Impls mappen auf `<Text>` mit entsprechendem fontWeight/Size. */
431
+ export type HeadingProps = {
432
+ readonly variant?: "page" | "section";
433
+ readonly children: ReactNode;
434
+ readonly testId?: string;
435
+ };
436
+
437
+ /** Modal-Dialog für Bestätigungen oder kompakte Sub-Forms. Web rendert
438
+ * Radix-Dialog (Focus-Trap, Esc-Schließen, Overlay-Click); Native
439
+ * würde ein Native-Modal nutzen. Apps öffnen den Dialog über einen
440
+ * External-State (`open` + `onOpenChange`); Confirm-Action läuft
441
+ * durch `onConfirm`, Cancel klappt zu via `onOpenChange(false)`.
442
+ *
443
+ * Variant `danger` markiert destruktive Bestätigungen visuell
444
+ * (rote Confirm-Button-Klasse), `default` für neutrale Dialoge. */
445
+ export type DialogProps = {
446
+ readonly open: boolean;
447
+ readonly onOpenChange: (open: boolean) => void;
448
+ readonly title: string;
449
+ /** Optional Beschreibung — typisch ein Satz der die Konsequenz
450
+ * erklärt ("Diese Aktion lässt sich nicht rückgängig machen."). */
451
+ readonly description?: string;
452
+ /** Confirm-Button-Label. Default kommt aus i18n
453
+ * (`kumiko.dialog.confirm`). */
454
+ readonly confirmLabel?: string;
455
+ /** Cancel-Button-Label. Default `kumiko.dialog.cancel`. */
456
+ readonly cancelLabel?: string;
457
+ /** `default` = Confirm primary, `danger` = Confirm danger. */
458
+ readonly variant?: "default" | "danger";
459
+ /** Wird gefeuert wenn der User Confirm drückt. Async-Funktion ist
460
+ * ok — Dialog setzt automatisch loading-State, ruft danach
461
+ * onOpenChange(false). */
462
+ readonly onConfirm: () => void | Promise<void>;
463
+ /** Optional zusätzlicher Inhalt zwischen description und Buttons
464
+ * (z.B. ein Input wenn der Dialog auch Eingaben sammelt). */
465
+ readonly children?: ReactNode;
466
+ readonly testId?: string;
467
+ };
468
+
469
+ // ---- Core-Registry (Kumiko-eigene Primitives) ----
470
+
471
+ export type CorePrimitives = {
472
+ readonly Button: ComponentType<ButtonProps>;
473
+ readonly Banner: ComponentType<BannerProps>;
474
+ readonly Field: ComponentType<FieldProps>;
475
+ readonly Input: ComponentType<InputProps>;
476
+ readonly DataTable: ComponentType<DataTableProps>;
477
+ readonly Form: ComponentType<FormProps>;
478
+ readonly Section: ComponentType<SectionProps>;
479
+ readonly Grid: ComponentType<GridProps>;
480
+ readonly GridCell: ComponentType<GridCellProps>;
481
+ readonly Text: ComponentType<TextProps>;
482
+ readonly Heading: ComponentType<HeadingProps>;
483
+ readonly Dialog: ComponentType<DialogProps>;
484
+ };
485
+
486
+ /** Offene Extension-Zone für App-eigene Primitives. Devs erweitern
487
+ * dieses Interface via TypeScript Module-Augmentation:
488
+ *
489
+ * declare module "@cosmicdrift/kumiko-renderer" {
490
+ * interface AppPrimitives {
491
+ * Chip: ComponentType<ChipProps>;
492
+ * Accordion: ComponentType<AccordionProps>;
493
+ * }
494
+ * }
495
+ *
496
+ * Nach der Augmentation tauchen die Keys in `PrimitivesRegistry`,
497
+ * `createKumikoApp({ primitives: { Chip, Accordion } })`, und
498
+ * `usePrimitives().Chip` auf — TypeScript-gestützt. Kumikos eigene
499
+ * Components nutzen ausschließlich `CorePrimitives`; App-Primitives
500
+ * sind ausschließlich für Dev-Code (Custom-Screens, Shell, eigene
501
+ * Components). */
502
+ // biome-ignore lint/suspicious/noEmptyInterface: extension point for module augmentation
503
+ export interface AppPrimitives {}
504
+
505
+ export type PrimitivesRegistry = CorePrimitives & AppPrimitives;
506
+
507
+ // ---- Context + Provider + Hook ----
508
+
509
+ const PrimitivesContext = createContext<PrimitivesRegistry | undefined>(undefined);
510
+
511
+ export type PrimitivesProviderProps = {
512
+ readonly children: ReactNode;
513
+ readonly value: PrimitivesRegistry;
514
+ };
515
+
516
+ export function PrimitivesProvider({ children, value }: PrimitivesProviderProps): ReactNode {
517
+ return <PrimitivesContext.Provider value={value}>{children}</PrimitivesContext.Provider>;
518
+ }
519
+
520
+ export function usePrimitives(): PrimitivesRegistry {
521
+ const registry = useContext(PrimitivesContext);
522
+ if (registry === undefined) {
523
+ throw new Error(
524
+ "usePrimitives: no <PrimitivesProvider> mounted. Wrap your app in one (createKumikoApp does this for you with defaultPrimitives from @cosmicdrift/kumiko-renderer-web).",
525
+ );
526
+ }
527
+ return registry;
528
+ }
@@ -0,0 +1,56 @@
1
+ // Live-Events-Contract, plattform-neutral. Die Plattform (Web mit
2
+ // EventSource, Native mit polyfill) liefert einen `LiveEventSubscriber`
3
+ // via `<LiveEventsProvider value={...}>`; der shared-Layer konsumiert
4
+ // nur das Interface.
5
+ //
6
+ // Warum ein Context statt Module-Singleton wie vor dem Split: ein
7
+ // Module-Singleton koppelt an eine globale Verbindung. Mit Context
8
+ // kann der Caller pro Tree eine andere Subscribe-Quelle durchreichen
9
+ // — nützlich in Tests (Fake-Feed), bei Multi-Tenant-Bridges, und
10
+ // beim Native-Renderer wo die Verbindungs-Lifecycle oft nicht dem
11
+ // App-Lifecycle entspricht.
12
+
13
+ import { createContext, type ReactNode, useContext } from "react";
14
+
15
+ export type LiveEvent = {
16
+ readonly type: string;
17
+ readonly data: {
18
+ readonly id: string;
19
+ readonly aggregateType: string;
20
+ readonly version: number;
21
+ readonly payload: unknown;
22
+ readonly createdAt: string;
23
+ };
24
+ };
25
+
26
+ /** Abonniert Live-Events für eine Entity (aggregateType). Returnt die
27
+ * Unsubscribe-Funktion. Mehrere Subscriptions parallel auf dieselbe
28
+ * Entity sind erlaubt — jede bekommt ihr eigenes Event. */
29
+ export type LiveEventSubscriber = (
30
+ entityName: string,
31
+ listener: (event: LiveEvent) => void,
32
+ ) => () => void;
33
+
34
+ // Wenn kein Provider da ist, liefern wir einen No-op-Subscriber statt
35
+ // zu crashen. Grund: useQuery({ live: true }) kann optimistisch
36
+ // ausgehakt werden ohne den ganzen Baum abzureißen, wenn die Plattform
37
+ // z.B. SSE temporär deaktiviert hat. Fehler-Signale würden stumm
38
+ // geschluckt — das ist explizit dokumentiert.
39
+ const noopSubscriber: LiveEventSubscriber = () => () => {};
40
+
41
+ const LiveEventsContext = createContext<LiveEventSubscriber>(noopSubscriber);
42
+
43
+ export type LiveEventsProviderProps = {
44
+ readonly children: ReactNode;
45
+ readonly value: LiveEventSubscriber;
46
+ };
47
+
48
+ export function LiveEventsProvider({ children, value }: LiveEventsProviderProps): ReactNode {
49
+ return <LiveEventsContext.Provider value={value}>{children}</LiveEventsContext.Provider>;
50
+ }
51
+
52
+ /** Hook für useQuery-like Consumer. Liefert die Subscribe-Function
53
+ * aus dem Context. Wenn kein Provider da ist, ist's ein No-op. */
54
+ export function useLiveEvents(): LiveEventSubscriber {
55
+ return useContext(LiveEventsContext);
56
+ }