@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,59 @@
1
+ import type { Dispatcher, DispatcherStatus } from "@cosmicdrift/kumiko-headless";
2
+ import { createContext, type ReactNode, useContext } from "react";
3
+ import { useStore } from "../hooks/use-store";
4
+
5
+ // React Context threading the Dispatcher through the tree. An app
6
+ // wraps its root in <DispatcherProvider dispatcher={createLiveDispatcher()}>
7
+ // and every hook below reaches into that context instead of taking the
8
+ // dispatcher as a prop on every call site. Same pattern most routers
9
+ // and query-libraries use; nothing novel, but worth spelling out once.
10
+ //
11
+ // The context holds the Dispatcher INSTANCE, not a factory — a single
12
+ // dispatcher per app (rebuilding would wipe its in-flight tracking and
13
+ // status listeners). Tests wire a fake dispatcher in directly.
14
+
15
+ const DispatcherContext = createContext<Dispatcher | null>(null);
16
+
17
+ export type DispatcherProviderProps = {
18
+ readonly dispatcher: Dispatcher;
19
+ readonly children: ReactNode;
20
+ };
21
+
22
+ export function DispatcherProvider({ dispatcher, children }: DispatcherProviderProps): ReactNode {
23
+ return <DispatcherContext value={dispatcher}>{children}</DispatcherContext>;
24
+ }
25
+
26
+ // Reads the ambient Dispatcher. Throws instead of returning null when
27
+ // no provider is mounted — a dispatcher-less hook is always a
28
+ // developer error (the app forgot to wrap its root) and surfacing it
29
+ // early beats debugging "why did my write silently do nothing" later.
30
+ export function useDispatcher(): Dispatcher {
31
+ const dispatcher = useContext(DispatcherContext);
32
+ if (!dispatcher) {
33
+ throw new Error(
34
+ "useDispatcher: no <DispatcherProvider> mounted above this component. Wrap your app root with <DispatcherProvider dispatcher={createLiveDispatcher()}>.",
35
+ );
36
+ }
37
+ return dispatcher;
38
+ }
39
+
40
+ // Soft-Variante: null wenn kein Provider mounted statt throw. Für
41
+ // Components die einen Dispatcher OPTIONAL brauchen (z.B. KumikoScreen
42
+ // rendert mit/ohne rowActions; die Test-Suite mountet kein Dispatcher
43
+ // wenn der Screen keine Mutation triggert). Caller ist zuständig dass
44
+ // er bei null einen sinnvollen Fallback hat (typisch: kein Action-Wiring).
45
+ export function useOptionalDispatcher(): Dispatcher | undefined {
46
+ return useContext(DispatcherContext) ?? undefined;
47
+ }
48
+
49
+ // Subscribes to online/offline/syncing transitions. The dispatcher
50
+ // exposes `statusStore: Store<DispatcherStatus>` directly; useStore is
51
+ // the canonical React-binding for that contract.
52
+ //
53
+ // Server rendering: useStore reads the store's getServerSnapshot which
54
+ // returns the same snapshot the client sees on hydration — for the
55
+ // live-dispatcher that's "online" (the optimistic boot default), so
56
+ // no flash on hydration.
57
+ export function useDispatcherStatus(): DispatcherStatus {
58
+ return useStore(useDispatcher().statusStore);
59
+ }
@@ -0,0 +1,18 @@
1
+ // Tier 2.7e Audit-Fix #7: Reference-Lookup-Limits zentral.
2
+ //
3
+ // Zwei verschiedene Use-Cases mit unterschiedlichen Default-Limits:
4
+ // - Combobox-Edit (single User picks one): 50 reicht weil
5
+ // typed-search-Fallback aktiv ist (REMOTE_SEARCH_DEBOUNCE_MS).
6
+ // - List-Bulk-Display (Cell-Render für viele Rows): 200 weil wir
7
+ // pro Reference-Spalte einmal pro Page laden, nicht pro Cell.
8
+ //
9
+ // Apps können die Defaults überschreiben indem sie die Konstanten
10
+ // hier neu setzen — pro feature/app gibt's heute keine Override-API
11
+ // (das wäre eine Erweiterung in createKumikoApp, separater Sprint).
12
+ // Die Konstanten leben in einem eigenen Modul damit Future-Author
13
+ // sie an einer Stelle findet statt verstreut zwischen render-field
14
+ // und use-reference-lookup.
15
+
16
+ export const REFERENCE_COMBOBOX_LIMIT = 50;
17
+ export const REFERENCE_LIST_LOOKUP_LIMIT = 200;
18
+ export const REFERENCE_SEARCH_DEBOUNCE_MS = 300;
@@ -0,0 +1,88 @@
1
+ import type {
2
+ FormController,
3
+ FormControllerOptions,
4
+ FormSnapshot,
5
+ FormValues,
6
+ } from "@cosmicdrift/kumiko-headless";
7
+ import { createFormController } from "@cosmicdrift/kumiko-headless";
8
+ import { useMemo, useSyncExternalStore } from "react";
9
+ import { useDispatcher } from "../context/dispatcher-context";
10
+
11
+ // Thin React wrapper around createFormController. Returns both the
12
+ // controller (imperative — setField, submit, reset) and the current
13
+ // snapshot (reactive — values, errors, isDirty). React subscribes via
14
+ // useSyncExternalStore so re-renders happen exactly when the snapshot
15
+ // reference changes, which the controller guarantees.
16
+ //
17
+ // The `submit.dispatcher` config is filled from context if omitted,
18
+ // so the typical call site is just:
19
+ // const { snapshot, controller } = useForm({
20
+ // initial: { ... },
21
+ // schema,
22
+ // submit: { type: "foo:create" },
23
+ // });
24
+ // and the hook wires the ambient DispatcherProvider's dispatcher in.
25
+
26
+ export type UseFormOptions<TValues extends FormValues, TCtx = unknown> = Omit<
27
+ FormControllerOptions<TValues, TCtx>,
28
+ "submit"
29
+ > & {
30
+ // `submit` here takes everything the ui-core SubmitConfig takes
31
+ // EXCEPT dispatcher — that comes from context. Passing an explicit
32
+ // dispatcher here (e.g. from a test) overrides the context one.
33
+ readonly submit?: Omit<
34
+ NonNullable<FormControllerOptions<TValues, TCtx>["submit"]>,
35
+ "dispatcher"
36
+ > & {
37
+ readonly dispatcher?: NonNullable<FormControllerOptions<TValues, TCtx>["submit"]>["dispatcher"];
38
+ };
39
+ };
40
+
41
+ export type UseFormResult<TValues extends FormValues> = {
42
+ readonly controller: FormController<TValues>;
43
+ readonly snapshot: FormSnapshot<TValues>;
44
+ };
45
+
46
+ export function useForm<TValues extends FormValues, TCtx = unknown>(
47
+ options: UseFormOptions<TValues, TCtx>,
48
+ ): UseFormResult<TValues> {
49
+ const contextDispatcher = useDispatcher();
50
+
51
+ // The controller is created once per mount. `options` mutates across
52
+ // renders in normal React usage (closures get new references), but
53
+ // the controller's behaviour depends only on the initial shape — we
54
+ // deliberately don't re-create on option-identity change, which
55
+ // would wipe in-flight state and "forget" dirty edits on every
56
+ // parent re-render. If the app needs a reset, call controller.reset()
57
+ // or re-mount the form (key prop).
58
+ // Controller lifetime = hook lifetime. Options deliberately NOT in
59
+ // the deps array; re-creating on option-identity change would wipe
60
+ // in-flight state and dirty edits on every parent re-render.
61
+ // biome-ignore lint/correctness/useExhaustiveDependencies: intentional — controller is lifetime-scoped, not render-scoped
62
+ const controller = useMemo<FormController<TValues>>(() => {
63
+ // Default the dispatcher to the ambient one, then hand ui-core
64
+ // a fully-populated SubmitConfig. The input signature lets the
65
+ // caller omit `dispatcher`; the output shape createFormController
66
+ // expects requires it.
67
+ type FullOptions = FormControllerOptions<TValues, TCtx>;
68
+ const rawSubmit = options.submit;
69
+ const submitConfig: FullOptions["submit"] = rawSubmit
70
+ ? { ...rawSubmit, dispatcher: rawSubmit.dispatcher ?? contextDispatcher }
71
+ : undefined;
72
+ const controllerOptions: FullOptions = {
73
+ ...(options as FullOptions),
74
+ ...(submitConfig !== undefined && { submit: submitConfig }),
75
+ };
76
+ return createFormController<TValues, TCtx>(controllerOptions);
77
+ }, []);
78
+
79
+ const snapshot = useSyncExternalStore(
80
+ (notify) => controller.subscribe(notify),
81
+ () => controller.getSnapshot(),
82
+ // SSR snapshot: same as getSnapshot(). The controller's initial
83
+ // snapshot is deterministic from options.initial, safe for server.
84
+ () => controller.getSnapshot(),
85
+ );
86
+
87
+ return { controller, snapshot };
88
+ }
@@ -0,0 +1,113 @@
1
+ // useListUrlState — bündelt sort/dir/page/q einer entityList in einen
2
+ // per-screen-id namespaced URL-State. Mit Screen-ID-Prefix damit zwei
3
+ // Listen auf derselben Route (z.B. Dashboard mit Orders + Incidents)
4
+ // nicht über dieselben Query-Keys streiten.
5
+ //
6
+ // Param-Schema pro Liste:
7
+ // <screenId>.sort — field name (string)
8
+ // <screenId>.dir — "asc" | "desc"
9
+ // <screenId>.q — search term (URL-encoded)
10
+ // <screenId>.page — 1-based page number (nur bei pagination="pages")
11
+ //
12
+ // Schreibt mit setSearchParams (replaceState — kein push), damit
13
+ // Sort/Filter-Toggles nicht die Browser-History fluten.
14
+
15
+ import { useCallback, useMemo } from "react";
16
+ import { useNav } from "../app/nav";
17
+ import type { DataTableSort, DataTableSortDir } from "../primitives";
18
+
19
+ // ListSort + DataTableSort hatten dieselbe Shape und drohten zu driften
20
+ // — aliased auf den primitives-Type (eine Quelle, kein Cast in RenderList).
21
+ export type ListSortDir = DataTableSortDir;
22
+ export type ListSort = DataTableSort;
23
+
24
+ export type ListUrlState = {
25
+ /** Aktive Sortierung (oder null = unsorted, Server liefert Default-Order). */
26
+ readonly sort: ListSort | null;
27
+ /** Search-Term (leer wenn nicht gesetzt). */
28
+ readonly q: string;
29
+ /** 1-basierte Page-Nummer. Bei pagination="infinite" oder false ist
30
+ * der Wert ignoriert; Caller liest ihn nur wenn relevant. */
31
+ readonly page: number;
32
+ };
33
+
34
+ export type ListUrlStateApi = ListUrlState & {
35
+ /** Setzt sort + dir atomar. null = unsorted (löscht beide Keys). */
36
+ readonly setSort: (next: ListSort | null) => void;
37
+ /** Setzt den Search-Term. Empty-String löscht den Key. Caller debounced
38
+ * selber (z.B. useDebouncedCallback in der Search-Input-Komponente). */
39
+ readonly setQ: (next: string) => void;
40
+ /** Setzt die Page. 1 oder kleiner löscht den Key (Default-Page). */
41
+ readonly setPage: (next: number) => void;
42
+ };
43
+
44
+ // `.` als Trenner: lesbar (`?orders.sort=name`), kollidiert nicht mit
45
+ // üblichen Field-Namen (kebab- oder camelCase ohne Punkt). Boot-Validator
46
+ // pinnt screen.id ohne Punkt — siehe boot-validator entityList Section.
47
+ function key(screenId: string, suffix: string): string {
48
+ return `${screenId}.${suffix}`;
49
+ }
50
+
51
+ function parseDir(value: string | undefined): ListSortDir | undefined {
52
+ return value === "asc" || value === "desc" ? value : undefined;
53
+ }
54
+
55
+ function parsePage(value: string | undefined): number {
56
+ if (value === undefined) return 1;
57
+ const n = Number.parseInt(value, 10);
58
+ return Number.isFinite(n) && n > 0 ? n : 1;
59
+ }
60
+
61
+ export function useListUrlState(screenId: string): ListUrlStateApi {
62
+ const nav = useNav();
63
+ const params = nav.searchParams;
64
+
65
+ const sort = useMemo<ListSort | null>(() => {
66
+ const field = params[key(screenId, "sort")];
67
+ const dir = parseDir(params[key(screenId, "dir")]);
68
+ if (field === undefined || field === "" || dir === undefined) return null;
69
+ return { field, dir };
70
+ }, [params, screenId]);
71
+
72
+ const q = params[key(screenId, "q")] ?? "";
73
+ const page = parsePage(params[key(screenId, "page")]);
74
+
75
+ const setSort = useCallback(
76
+ (next: ListSort | null) => {
77
+ // Atomares Update: bei jedem Sort-Wechsel resetten wir auch die
78
+ // Page (sonst wäre der User auf "Seite 5 von alter Sortierung"
79
+ // hängen, was visuell verwirrend ist). Page-Reset gilt auch bei
80
+ // null — gleiche Logik.
81
+ nav.setSearchParams({
82
+ [key(screenId, "sort")]: next === null ? null : next.field,
83
+ [key(screenId, "dir")]: next === null ? null : next.dir,
84
+ [key(screenId, "page")]: null,
85
+ });
86
+ },
87
+ [nav, screenId],
88
+ );
89
+
90
+ const setQ = useCallback(
91
+ (next: string) => {
92
+ // Search-Change resettet Page (gleicher Grund wie Sort) UND
93
+ // Sort? Nein — User kann mit aktivem Sort suchen wollen, das
94
+ // soll die Sortierung nicht zerlegen. Nur Page wird gereset.
95
+ nav.setSearchParams({
96
+ [key(screenId, "q")]: next === "" ? null : next,
97
+ [key(screenId, "page")]: null,
98
+ });
99
+ },
100
+ [nav, screenId],
101
+ );
102
+
103
+ const setPage = useCallback(
104
+ (next: number) => {
105
+ nav.setSearchParams({
106
+ [key(screenId, "page")]: next <= 1 ? null : String(next),
107
+ });
108
+ },
109
+ [nav, screenId],
110
+ );
111
+
112
+ return { sort, q, page, setSort, setQ, setPage };
113
+ }
@@ -0,0 +1,129 @@
1
+ import type { DispatcherError } from "@cosmicdrift/kumiko-headless";
2
+ import { useCallback, useEffect, useRef, useState } from "react";
3
+ import { useDispatcher } from "../context/dispatcher-context";
4
+ import { useLiveEvents } from "../sse/live-events";
5
+
6
+ // React wrapper around dispatcher.query. Fires on mount, re-fires
7
+ // whenever `type` or the serialized payload change, and exposes a
8
+ // `refetch` imperative hook for after-mutation reloads.
9
+ //
10
+ // Cancellation: each fetch owns an AbortController; a newer fetch
11
+ // (change in deps or manual refetch) aborts the older one's network
12
+ // call before firing, so a slow request can't overwrite a fresh one.
13
+ // Unmount aborts any in-flight call too — React will throw the
14
+ // "state on unmounted" warning if we set state after unmount, and
15
+ // aborting pre-emptively makes that path a no-op.
16
+ //
17
+ // Result shape modeled on the dispatcher's own QueryResult: a
18
+ // discriminated state so callers can branch on `loading | data | error`
19
+ // without undefined-juggling. The first render is `loading: true,
20
+ // data: null, error: null` (no optimistic "online" cache — add that
21
+ // later if needed).
22
+
23
+ export type UseQueryResult<TData> = {
24
+ readonly data: TData | null;
25
+ readonly error: DispatcherError | null;
26
+ readonly loading: boolean;
27
+ readonly refetch: () => Promise<void>;
28
+ };
29
+
30
+ export type UseQueryOptions = {
31
+ // When false, skips the automatic fetch-on-mount and re-fetch on
32
+ // dep-change. Useful for lazy queries that only run after a user
33
+ // action. `refetch()` still works as an imperative trigger.
34
+ readonly enabled?: boolean;
35
+ // When true, subscribe to SSE events for the entity this query
36
+ // targets and refetch on any create/update/delete/restore event.
37
+ // The entity-name is parsed from the query type using Kumiko's
38
+ // `<feature>:query:<entity>:<verb>` convention — if the type
39
+ // doesn't follow that shape, live-mode is a no-op.
40
+ readonly live?: boolean;
41
+ };
42
+
43
+ // Extract the entity-name from a standard Kumiko query type. Returns
44
+ // undefined for non-conforming types so the live-mode silently skips
45
+ // them instead of subscribing to a channel no event will ever match.
46
+ function entityFromQueryType(type: string): string | undefined {
47
+ // Expected shape: "<feature>:query:<entity>:<verb>"
48
+ const parts = type.split(":");
49
+ if (parts.length !== 4) return undefined;
50
+ if (parts[1] !== "query") return undefined;
51
+ return parts[2];
52
+ }
53
+
54
+ export function useQuery<TData = unknown>(
55
+ type: string,
56
+ payload: unknown,
57
+ options: UseQueryOptions = {},
58
+ ): UseQueryResult<TData> {
59
+ const dispatcher = useDispatcher();
60
+ const { enabled = true, live = false } = options;
61
+
62
+ const [data, setData] = useState<TData | null>(null);
63
+ const [error, setError] = useState<DispatcherError | null>(null);
64
+ const [loading, setLoading] = useState<boolean>(enabled);
65
+
66
+ // Track the AbortController for the most recent fetch so a newer
67
+ // call can cancel the older one. Ref, not state, because the
68
+ // controller identity shouldn't trigger a re-render.
69
+ const activeCtrl = useRef<AbortController | null>(null);
70
+
71
+ // Serialize payload so object-identity-changes across renders (the
72
+ // caller building a fresh `{}` every render) don't loop. Strings are
73
+ // stable by reference in React's dep-array check.
74
+ const payloadKey = JSON.stringify(payload);
75
+
76
+ // `dispatcher` is stable (context identity); `type` + `payloadKey`
77
+ // are the meaningful re-run triggers. `payload` itself is intentionally
78
+ // not in deps — it's serialized into payloadKey above.
79
+ // biome-ignore lint/correctness/useExhaustiveDependencies: payload goes through payloadKey
80
+ const run = useCallback(async (): Promise<void> => {
81
+ // Abort whatever's in flight. observer on Safari 17+ handles this
82
+ // without raising a fetch-throw in the previous caller — they
83
+ // already set the error path.
84
+ activeCtrl.current?.abort();
85
+ const ctrl = new AbortController();
86
+ activeCtrl.current = ctrl;
87
+
88
+ setLoading(true);
89
+ const result = await dispatcher.query<TData>(type, payload, { signal: ctrl.signal });
90
+ // Don't update state if a newer fetch has already taken over.
91
+ if (ctrl.signal.aborted) return;
92
+ if (result.isSuccess) {
93
+ setData(result.data);
94
+ setError(null);
95
+ } else {
96
+ // A cancelled request comes back with code "aborted" from the
97
+ // dispatcher — skip the state update, another run replaces it.
98
+ if (result.error.code === "aborted") return;
99
+ setError(result.error);
100
+ }
101
+ setLoading(false);
102
+ }, [dispatcher, type, payloadKey]);
103
+
104
+ useEffect(() => {
105
+ if (!enabled) {
106
+ setLoading(false);
107
+ return;
108
+ }
109
+ void run();
110
+ return () => {
111
+ activeCtrl.current?.abort();
112
+ };
113
+ }, [enabled, run]);
114
+
115
+ // Live-mode: auf SSE-Events für die Query-Entity hören und refetchen.
116
+ // Separater Effect, damit eine Änderung an `live` oder `type` das
117
+ // Subscription-Lifecycle genau einmal durchwalzt.
118
+ const subscribeLive = useLiveEvents();
119
+ useEffect(() => {
120
+ if (!live || !enabled) return;
121
+ const entity = entityFromQueryType(type);
122
+ if (entity === undefined) return;
123
+ return subscribeLive(entity, () => {
124
+ void run();
125
+ });
126
+ }, [live, enabled, type, run, subscribeLive]);
127
+
128
+ return { data, error, loading, refetch: run };
129
+ }
@@ -0,0 +1,54 @@
1
+ // Tier 2.7e-4: Renderer-Side Eagerload für Reference-Felder.
2
+ //
3
+ // Pro Reference-Spalte einer Liste wird einmal `<feature>:query:
4
+ // <refEntity>:list` (limit:200) gerufen, eine Map<uuid, displayValue>
5
+ // gebaut, und der Renderer nutzt sie als Cell-Display-Renderer.
6
+ //
7
+ // Strategy-Trade-offs:
8
+ // - Server-side Drizzle-Joins wären effizienter (eine Query statt
9
+ // N+1), würden aber die executor-API um lookupTables erweitern
10
+ // müssen. Für das MVP geht der Renderer-Side-Pfad vor.
11
+ // - Limit:200 ist eine harte UX-Grenze: bei mehr Entries in der
12
+ // referenced Entity zeigen die letzten Rows nur noch UUIDs.
13
+ // Searchable-Combobox (Tier 2.1c) + Server-Eagerload mit
14
+ // ID-Whitelist heben das später auf.
15
+ //
16
+ // Dieser Hook ist call-stable: Lookup-Map wird durch useQuery's
17
+ // internen Cache shared zwischen List + Edit-Form für die gleiche
18
+ // Entity, Live-Updates kommen via SSE (use-query-live).
19
+
20
+ import { useMemo } from "react";
21
+ import { REFERENCE_LIST_LOOKUP_LIMIT } from "./reference-limits";
22
+ import { useQuery } from "./use-query";
23
+
24
+ export type ReferenceLookupMap = ReadonlyMap<string, string>;
25
+
26
+ /** Bulk-Lookup für eine einzelne Reference-Spalte. Liefert eine Map
27
+ * von UUID → Display-Value (aus labelField). Während die Query lädt,
28
+ * ist die Map leer; der Caller fällt dann auf den UUID-Fallback.
29
+ *
30
+ * `featureName` ist hier das **target**-Feature (refFeature aus
31
+ * ViewModel), nicht das current Feature — Cross-Feature-Refs lookup
32
+ * laufen damit gegen `<refFeature>:query:<refEntity>:list`. */
33
+ export function useReferenceLookup(
34
+ featureName: string,
35
+ refEntity: string,
36
+ labelField: string,
37
+ ): { readonly map: ReferenceLookupMap; readonly loading: boolean } {
38
+ const queryQn = `${featureName}:query:${refEntity}:list`;
39
+ const result = useQuery<{ rows: ReadonlyArray<Record<string, unknown>> }>(queryQn, {
40
+ limit: REFERENCE_LIST_LOOKUP_LIMIT,
41
+ });
42
+ const map = useMemo(() => {
43
+ const out = new Map<string, string>();
44
+ for (const row of result.data?.rows ?? []) {
45
+ const id = row["id"];
46
+ if (id === undefined || id === null) continue;
47
+ const idStr = String(id);
48
+ const label = row[labelField] ?? id;
49
+ out.set(idStr, String(label));
50
+ }
51
+ return out;
52
+ }, [result.data, labelField]);
53
+ return { map, loading: result.loading };
54
+ }
@@ -0,0 +1,47 @@
1
+ import type { Store } from "@cosmicdrift/kumiko-headless";
2
+ import { useRef, useSyncExternalStore } from "react";
3
+
4
+ // React-Bindings für Kumikos Store-Primitive (`createStore` aus
5
+ // @cosmicdrift/kumiko-headless). Beide Hooks sind dünne Wrapper um React's
6
+ // useSyncExternalStore — die ganze Subscribe/Notify-Mechanik lebt im
7
+ // Store selbst, hier wird nur React verdrahtet.
8
+ //
9
+ // useStore: gibt den ganzen Snapshot zurück. Re-rendert wenn der
10
+ // Store seine Identität wechselt. Object.is-Gate im Store selbst
11
+ // verhindert no-op-Updates.
12
+ //
13
+ // useStoreSelector: gibt eine abgeleitete Sicht zurück. Selectors die
14
+ // Object-/Array-Literale zurückgeben (z.B. `s => ({ a, b })`) erzeugen
15
+ // pro Render eine neue Identität — useSyncExternalStore würde das als
16
+ // "Change" lesen und in eine Re-Render-Schleife laufen. Der dritte
17
+ // Arg `equals` dient genau dafür: gleiche Auswahl → cached
18
+ // Identität zurück, kein Re-Render.
19
+
20
+ export function useStore<T>(store: Store<T>): T {
21
+ return useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
22
+ }
23
+
24
+ export function useStoreSelector<T, S>(
25
+ store: Store<T>,
26
+ select: (snapshot: T) => S,
27
+ equals: (a: S, b: S) => boolean = Object.is,
28
+ ): S {
29
+ // Cache der letzten Auswahl. Beim nächsten getSnapshot-Call vergleicht
30
+ // der Wrapper die neu berechnete Auswahl mit dem Cache und gibt — falls
31
+ // gleich — die cached Identität zurück. Das ist der Trick, der
32
+ // Selector-Returns wie `s => ({ a, b })` stabil hält.
33
+ const lastSelected = useRef<S>(undefined as S);
34
+ const initialized = useRef(false);
35
+
36
+ const getSelectedSnapshot = (): S => {
37
+ const next = select(store.getSnapshot());
38
+ if (initialized.current && equals(lastSelected.current, next)) {
39
+ return lastSelected.current;
40
+ }
41
+ lastSelected.current = next;
42
+ initialized.current = true;
43
+ return next;
44
+ };
45
+
46
+ return useSyncExternalStore(store.subscribe, getSelectedSnapshot, getSelectedSnapshot);
47
+ }
@@ -0,0 +1,94 @@
1
+ // Framework-Default-Bundle. Strings die die Renderer-Components hart
2
+ // brauchen (Save/Cancel/Delete-Buttons, Empty-States, Search-Placeholder,
3
+ // Nav-Toggle-aria-Labels, Validation-Reasons). createKumikoApp hängt das
4
+ // als ALLERLETZTEN Fallback in den LocaleProvider — Apps können
5
+ // einzelne Keys via clientFeatures.translations überschreiben.
6
+ //
7
+ // Convention: alle Keys mit `kumiko.`-Prefix damit sie nicht mit
8
+ // App-Keys kollidieren. Sub-Pfade gruppieren nach Bereich (actions /
9
+ // list / nav / form / validation).
10
+
11
+ import type { TranslationsByLocale } from "./i18n";
12
+
13
+ export const kumikoDefaultTranslations: TranslationsByLocale = {
14
+ de: {
15
+ // Actions — Buttons in RenderEdit, RenderList, Confirm-Dialogen.
16
+ "kumiko.actions.save": "Speichern",
17
+ "kumiko.actions.cancel": "Abbrechen",
18
+ "kumiko.actions.delete": "Löschen",
19
+ "kumiko.actions.delete-confirm": "Wirklich löschen?",
20
+ "kumiko.actions.reload": "Neu laden",
21
+ "kumiko.actions.create": "Neu",
22
+
23
+ // List — DataTable Toolbar, Empty-State, Search.
24
+ "kumiko.list.search-placeholder": "Suchen…",
25
+ "kumiko.list.empty.title": "Noch keine Einträge.",
26
+ "kumiko.list.empty.hint": "Lege den ersten an, um loszulegen.",
27
+ "kumiko.list.no-entries": "Keine Einträge.",
28
+
29
+ // Combobox — Tier 2.1c Searchable-Select.
30
+ "kumiko.combobox.search-placeholder": "Suchen…",
31
+ "kumiko.combobox.empty": "Keine Treffer.",
32
+ "kumiko.combobox.loading": "Lade…",
33
+ "kumiko.combobox.placeholder": "—",
34
+
35
+ // Nav — Sidebar Tree (Toggle-aria-Labels).
36
+ "kumiko.nav.expand": "Aufklappen",
37
+ "kumiko.nav.collapse": "Zuklappen",
38
+
39
+ // Dialog — Confirm-Buttons + Close-aria-Label.
40
+ "kumiko.dialog.confirm": "Bestätigen",
41
+ "kumiko.dialog.cancel": "Abbrechen",
42
+ "kumiko.dialog.close": "Schließen",
43
+
44
+ // Form — Standard-Errors (App-Code kann eigene zod-Reasons nutzen,
45
+ // diese sind die letzte Sicherheitsschicht).
46
+ "kumiko.form.error.generic": "Etwas ist schiefgegangen.",
47
+ "kumiko.form.error.version-conflict":
48
+ "Datensatz wurde zwischenzeitlich geändert. Lade neu und versuche es erneut.",
49
+
50
+ // Validation — Default-Reason-Codes aus dem Framework. App-Code
51
+ // kann eigene Codes via Validation-Hooks reinwerfen; die hier sind
52
+ // die generischen.
53
+ "kumiko.validation.required": "Pflichtfeld.",
54
+ "kumiko.validation.invalid": "Ungültiger Wert.",
55
+ "kumiko.validation.too-short": "Zu kurz (mindestens {min} Zeichen).",
56
+ "kumiko.validation.too-long": "Zu lang (höchstens {max} Zeichen).",
57
+ "kumiko.validation.out-of-range": "Wert außerhalb des erlaubten Bereichs.",
58
+ },
59
+ en: {
60
+ "kumiko.actions.save": "Save",
61
+ "kumiko.actions.cancel": "Cancel",
62
+ "kumiko.actions.delete": "Delete",
63
+ "kumiko.actions.delete-confirm": "Confirm delete?",
64
+ "kumiko.actions.reload": "Reload",
65
+ "kumiko.actions.create": "New",
66
+
67
+ "kumiko.list.search-placeholder": "Search…",
68
+ "kumiko.list.empty.title": "No entries yet.",
69
+ "kumiko.list.empty.hint": "Create the first one to get started.",
70
+ "kumiko.list.no-entries": "No entries.",
71
+
72
+ "kumiko.combobox.search-placeholder": "Search…",
73
+ "kumiko.combobox.empty": "No matches.",
74
+ "kumiko.combobox.loading": "Loading…",
75
+ "kumiko.combobox.placeholder": "—",
76
+
77
+ "kumiko.nav.expand": "Expand",
78
+ "kumiko.nav.collapse": "Collapse",
79
+
80
+ "kumiko.dialog.confirm": "Confirm",
81
+ "kumiko.dialog.cancel": "Cancel",
82
+ "kumiko.dialog.close": "Close",
83
+
84
+ "kumiko.form.error.generic": "Something went wrong.",
85
+ "kumiko.form.error.version-conflict":
86
+ "Record was modified in the meantime. Reload and try again.",
87
+
88
+ "kumiko.validation.required": "Required.",
89
+ "kumiko.validation.invalid": "Invalid value.",
90
+ "kumiko.validation.too-short": "Too short (at least {min} characters).",
91
+ "kumiko.validation.too-long": "Too long (at most {max} characters).",
92
+ "kumiko.validation.out-of-range": "Value out of allowed range.",
93
+ },
94
+ };