@cosmicdrift/kumiko-renderer 0.146.4 → 0.147.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer",
3
- "version": "0.146.4",
3
+ "version": "0.147.0",
4
4
  "description": "Platform-agnostic React renderer for Kumiko screens. Contains the shared logic — primitives-contract, hooks, KumikoScreen, navigation & SSE abstractions — that any platform-specific renderer (web, native) composes. No DOM, no EventSource, no react-dom.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -15,8 +15,8 @@
15
15
  }
16
16
  },
17
17
  "dependencies": {
18
- "@cosmicdrift/kumiko-framework": "0.146.4",
19
- "@cosmicdrift/kumiko-headless": "0.146.4",
18
+ "@cosmicdrift/kumiko-framework": "0.147.0",
19
+ "@cosmicdrift/kumiko-headless": "0.147.0",
20
20
  "react": "^19.2.6"
21
21
  },
22
22
  "devDependencies": {
@@ -0,0 +1,12 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { formatWhen } from "../format-when";
3
+
4
+ describe("formatWhen", () => {
5
+ test("formats a parseable ISO timestamp", () => {
6
+ expect(formatWhen("2024-01-01T00:00:00.000Z")).not.toBe("2024-01-01T00:00:00.000Z");
7
+ });
8
+
9
+ test("falls back to the raw value on an unparseable input", () => {
10
+ expect(formatWhen("garbage")).toBe("garbage");
11
+ });
12
+ });
@@ -52,6 +52,10 @@ function evalRowExtractor(
52
52
  return Object.fromEntries(Object.entries(extractor.map).map(([to, from]) => [to, row[from]]));
53
53
  }
54
54
 
55
+ function isWriteHandlerRowAction(action: RowAction): action is RowActionWriteHandler {
56
+ return action.kind === "writeHandler" || action.kind === undefined;
57
+ }
58
+
55
59
  // KumikoScreen picks up a ScreenDefinition from the schema by qn and
56
60
  // routes it to the right renderer based on `screen.type`. Command
57
61
  // qualification (`<feature>:write:<entity>:create` etc.) happens here
@@ -877,8 +881,8 @@ function EntityListBody({
877
881
  };
878
882
  }
879
883
  if (dispatcher === undefined) return null;
880
- if (action.kind !== "writeHandler" && action.kind !== undefined) return null;
881
- const writeAction = action as RowActionWriteHandler;
884
+ if (!isWriteHandlerRowAction(action)) return null;
885
+ const writeAction = action;
882
886
  const writeActionVisible = writeAction.visible;
883
887
  return {
884
888
  id: writeAction.id,
@@ -946,7 +950,16 @@ function EntityListBody({
946
950
  }),
947
951
  onTrigger: async () => {
948
952
  const payload = action.payload ?? {};
949
- await dispatcher.write(action.handler, payload);
953
+ const result = await dispatcher.write(action.handler, payload);
954
+ // Gleicher Surfacing-Zwang wie bei rowActions (Prod-Bug
955
+ // 2026-06-07): ein verschlucktes Failure-Result sah wie
956
+ // "nichts passiert" aus.
957
+ if (!result.isSuccess) {
958
+ throw new WriteFailedError(
959
+ result.error,
960
+ dispatcherErrorText(result.error, effectiveTranslate),
961
+ );
962
+ }
950
963
  },
951
964
  };
952
965
  })
@@ -1112,7 +1125,7 @@ function ProjectionListBody({
1112
1125
  // Failure-Result MUSS zum Error werden (sonst schließt der Confirm-
1113
1126
  // Dialog kommentarlos).
1114
1127
  if (dispatcher === undefined) continue;
1115
- const writeAction = action as RowActionWriteHandler;
1128
+ const writeAction = action;
1116
1129
  const writeVisible = writeAction.visible;
1117
1130
  out.push({
1118
1131
  id: writeAction.id,
@@ -1170,7 +1183,16 @@ function ProjectionListBody({
1170
1183
  confirmLabel: effectiveTranslate(action.confirmLabel),
1171
1184
  }),
1172
1185
  onTrigger: async () => {
1173
- await dispatcher.write(action.handler, action.payload ?? {});
1186
+ const result = await dispatcher.write(action.handler, action.payload ?? {});
1187
+ // Gleicher Surfacing-Zwang wie bei rowActions (Prod-Bug
1188
+ // 2026-06-07): ein verschlucktes Failure-Result sah wie
1189
+ // "nichts passiert" aus.
1190
+ if (!result.isSuccess) {
1191
+ throw new WriteFailedError(
1192
+ result.error,
1193
+ dispatcherErrorText(result.error, effectiveTranslate),
1194
+ );
1195
+ }
1174
1196
  },
1175
1197
  });
1176
1198
  }
@@ -247,8 +247,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
247
247
  [screen, entity, snapshot.values, translate, featureName],
248
248
  );
249
249
 
250
- // isFormEditable, not "hasEditableField" (653/2) true for an extension
251
- // section with no fields of its own too (it carries its own dirty/save).
250
+ // true for an extension section with no fields of its own too (it carries its own dirty/save).
252
251
  const isFormEditable = hasEditableSection(vm.sections);
253
252
 
254
253
  // Persistiert alle composed Extension-Sections mit der aufgelösten entityId.
@@ -148,7 +148,7 @@ export function RenderList(props: RenderListProps): ReactNode {
148
148
  // wären Column-Header raw i18n-Keys.
149
149
  const t = useTranslation();
150
150
  const translate: Translate = translateProp ?? t;
151
- const { DataTable, Button, Dialog, Input, Text } = usePrimitives();
151
+ const { DataTable, Button, Dialog, Input, Text, Banner } = usePrimitives();
152
152
 
153
153
  // Local Search-Buffer + Debounce. Externe Änderungen (Browser-Back,
154
154
  // Cross-Component-Reset) spiegeln wir per Sync-Effect zurück; Tipps
@@ -280,7 +280,13 @@ export function RenderList(props: RenderListProps): ReactNode {
280
280
  {hasHeaderSlot && <ListHeaderSlotMount screen={screen} />}
281
281
  {hasToolbarActions &&
282
282
  toolbarActions.map((a) => (
283
- <ToolbarActionView key={a.id} action={a} Button={Button} Dialog={Dialog} />
283
+ <ToolbarActionView
284
+ key={a.id}
285
+ action={a}
286
+ Button={Button}
287
+ Dialog={Dialog}
288
+ Banner={Banner}
289
+ />
284
290
  ))}
285
291
  {onCreate !== undefined && (
286
292
  <Button variant="primary" onClick={onCreate} testId="render-list-create">
@@ -423,18 +429,29 @@ function ToolbarActionView({
423
429
  action,
424
430
  Button,
425
431
  Dialog,
432
+ Banner,
426
433
  }: {
427
434
  readonly action: ToolbarActionButton;
428
435
  readonly Button: ReturnType<typeof usePrimitives>["Button"];
429
436
  readonly Dialog: ReturnType<typeof usePrimitives>["Dialog"];
437
+ readonly Banner: ReturnType<typeof usePrimitives>["Banner"];
430
438
  }): ReactNode {
431
439
  const [busy, setBusy] = useState(false);
432
440
  const [confirmOpen, setConfirmOpen] = useState(false);
441
+ // Toolbar-Actions haben (anders als rowActions, die über die DataTable-
442
+ // Primitive laufen) keine Toast-Primitive zur Verfügung — ein inline
443
+ // Error-Banner ist der surfacing-Pfad hier. Gleicher Grund wie bei
444
+ // rowActions (Prod-Bug 2026-06-07): ein verschlucktes Failure-Result
445
+ // sieht für den User wie "nichts passiert" aus.
446
+ const [errorText, setErrorText] = useState<string | null>(null);
433
447
 
434
448
  const trigger = async (): Promise<void> => {
435
449
  setBusy(true);
450
+ setErrorText(null);
436
451
  try {
437
452
  await action.onTrigger();
453
+ } catch (e) {
454
+ setErrorText(e instanceof Error ? e.message : String(e));
438
455
  } finally {
439
456
  setBusy(false);
440
457
  }
@@ -469,6 +486,11 @@ function ToolbarActionView({
469
486
  onConfirm={trigger}
470
487
  testId={`render-list-toolbar-action-${action.id}-dialog`}
471
488
  />
489
+ {errorText !== null && (
490
+ <Banner variant="error" testId={`render-list-toolbar-action-${action.id}-error`}>
491
+ {errorText}
492
+ </Banner>
493
+ )}
472
494
  </>
473
495
  );
474
496
  }
@@ -0,0 +1,6 @@
1
+ // Shared timestamp formatter for operator screens (audit log, job runs) —
2
+ // falls back to the raw ISO string on an unparseable value instead of "Invalid Date".
3
+ export function formatWhen(value: string): string {
4
+ const d = new Date(value);
5
+ return Number.isNaN(d.getTime()) ? value : d.toLocaleString();
6
+ }
@@ -0,0 +1,176 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
3
+ import { act, renderHook, waitFor } from "@testing-library/react";
4
+ import type { ReactNode } from "react";
5
+ import { DispatcherProvider } from "../../context/dispatcher-context";
6
+ import { useAiTextAction, useCompletion } from "../use-ai-text";
7
+
8
+ function makeDispatcher(query: Dispatcher["query"]): Dispatcher {
9
+ return {
10
+ query,
11
+ write: (async () => ({ isSuccess: true, data: {} })) as unknown as Dispatcher["write"],
12
+ batch: (async () => ({ isSuccess: true, results: [] })) as unknown as Dispatcher["batch"],
13
+ statusStore: {
14
+ getState: () => "online",
15
+ subscribe: () => () => {},
16
+ } as unknown as Dispatcher["statusStore"],
17
+ pendingWrites: () => [],
18
+ pendingFiles: () => [],
19
+ };
20
+ }
21
+
22
+ function wrapperFor(dispatcher: Dispatcher) {
23
+ return ({ children }: { readonly children: ReactNode }) => (
24
+ <DispatcherProvider dispatcher={dispatcher}>{children}</DispatcherProvider>
25
+ );
26
+ }
27
+
28
+ describe("useAiTextAction", () => {
29
+ test("success → state='success', result carries the text", async () => {
30
+ const dispatcher = makeDispatcher((async (_type: string, payload: unknown) => ({
31
+ isSuccess: true,
32
+ data: {
33
+ type: "text",
34
+ text: `echo:${(payload as { text: string }).text}`,
35
+ usage: { inputTokens: 1, outputTokens: 1 },
36
+ },
37
+ })) as unknown as Dispatcher["query"]);
38
+
39
+ const { result } = renderHook(() => useAiTextAction(), { wrapper: wrapperFor(dispatcher) });
40
+
41
+ expect(result.current.state).toBe("idle");
42
+ await act(async () => {
43
+ await result.current.run({ mode: "correct", text: "hi" });
44
+ });
45
+
46
+ expect(result.current.state).toBe("success");
47
+ expect(result.current.result).toEqual({
48
+ type: "text",
49
+ text: "echo:hi",
50
+ usage: { inputTokens: 1, outputTokens: 1 },
51
+ });
52
+ expect(result.current.error).toBeNull();
53
+ });
54
+
55
+ test("cap_exceeded error → state='cap-exceeded'", async () => {
56
+ const dispatcher = makeDispatcher((async () => ({
57
+ isSuccess: false,
58
+ error: { code: "cap_exceeded", message: "capped", i18nKey: "errors.cap" },
59
+ })) as unknown as Dispatcher["query"]);
60
+
61
+ const { result } = renderHook(() => useAiTextAction(), { wrapper: wrapperFor(dispatcher) });
62
+
63
+ await act(async () => {
64
+ await result.current.run({ mode: "correct", text: "hi" });
65
+ });
66
+
67
+ expect(result.current.state).toBe("cap-exceeded");
68
+ expect(result.current.error?.code).toBe("cap_exceeded");
69
+ });
70
+
71
+ test("feature_disabled error → state='unavailable' (graceful degradation)", async () => {
72
+ const dispatcher = makeDispatcher((async () => ({
73
+ isSuccess: false,
74
+ error: { code: "feature_disabled", message: "off", i18nKey: "errors.disabled" },
75
+ })) as unknown as Dispatcher["query"]);
76
+
77
+ const { result } = renderHook(() => useAiTextAction(), { wrapper: wrapperFor(dispatcher) });
78
+
79
+ await act(async () => {
80
+ await result.current.run({ mode: "complete", text: "hi" });
81
+ });
82
+
83
+ expect(result.current.state).toBe("unavailable");
84
+ });
85
+
86
+ test("reset clears state/result/error back to idle", async () => {
87
+ const dispatcher = makeDispatcher((async () => ({
88
+ isSuccess: false,
89
+ error: { code: "conflict", message: "boom", i18nKey: "errors.conflict" },
90
+ })) as unknown as Dispatcher["query"]);
91
+
92
+ const { result } = renderHook(() => useAiTextAction(), { wrapper: wrapperFor(dispatcher) });
93
+ await act(async () => {
94
+ await result.current.run({ mode: "correct", text: "hi" });
95
+ });
96
+ expect(result.current.state).toBe("error");
97
+
98
+ act(() => result.current.reset());
99
+ expect(result.current.state).toBe("idle");
100
+ expect(result.current.error).toBeNull();
101
+ expect(result.current.result).toBeNull();
102
+ });
103
+ });
104
+
105
+ describe("useCompletion", () => {
106
+ test("debounces requestCompletion — only the last call within the window fires", async () => {
107
+ let calls = 0;
108
+ const dispatcher = makeDispatcher((async (_type: string, payload: unknown) => {
109
+ calls++;
110
+ return {
111
+ isSuccess: true,
112
+ data: {
113
+ type: "text",
114
+ text: `suggestion for "${(payload as { text: string }).text}"`,
115
+ usage: { inputTokens: 1, outputTokens: 1 },
116
+ },
117
+ };
118
+ }) as unknown as Dispatcher["query"]);
119
+
120
+ const { result } = renderHook(() => useCompletion(20), { wrapper: wrapperFor(dispatcher) });
121
+
122
+ act(() => {
123
+ result.current.requestCompletion("a");
124
+ result.current.requestCompletion("ab");
125
+ result.current.requestCompletion("abc");
126
+ });
127
+
128
+ await waitFor(() => expect(result.current.suggestion).not.toBeNull(), { timeout: 1000 });
129
+
130
+ expect(calls).toBe(1);
131
+ expect(result.current.suggestion).toBe('suggestion for "abc"');
132
+ });
133
+
134
+ test("empty text resets immediately without a request", async () => {
135
+ let calls = 0;
136
+ const dispatcher = makeDispatcher((async () => {
137
+ calls++;
138
+ return {
139
+ isSuccess: true,
140
+ data: { type: "text", text: "x", usage: { inputTokens: 1, outputTokens: 1 } },
141
+ };
142
+ }) as unknown as Dispatcher["query"]);
143
+
144
+ const { result } = renderHook(() => useCompletion(10), { wrapper: wrapperFor(dispatcher) });
145
+
146
+ act(() => {
147
+ result.current.requestCompletion("");
148
+ });
149
+ await new Promise((r) => setTimeout(r, 30));
150
+
151
+ expect(calls).toBe(0);
152
+ expect(result.current.suggestion).toBeNull();
153
+ });
154
+
155
+ test("clear cancels a pending debounce and resets the suggestion", async () => {
156
+ let calls = 0;
157
+ const dispatcher = makeDispatcher((async () => {
158
+ calls++;
159
+ return {
160
+ isSuccess: true,
161
+ data: { type: "text", text: "x", usage: { inputTokens: 1, outputTokens: 1 } },
162
+ };
163
+ }) as unknown as Dispatcher["query"]);
164
+
165
+ const { result } = renderHook(() => useCompletion(30), { wrapper: wrapperFor(dispatcher) });
166
+
167
+ act(() => {
168
+ result.current.requestCompletion("hello");
169
+ });
170
+ act(() => result.current.clear());
171
+ await new Promise((r) => setTimeout(r, 60));
172
+
173
+ expect(calls).toBe(0);
174
+ expect(result.current.suggestion).toBeNull();
175
+ });
176
+ });
@@ -0,0 +1,171 @@
1
+ import type { DispatcherError } from "@cosmicdrift/kumiko-headless";
2
+ import { useCallback, useEffect, useRef, useState } from "react";
3
+ import { useDispatcher } from "../context/dispatcher-context";
4
+
5
+ // AiTextField/AiTextArea's client-side surface. Deliberately duplicates the
6
+ // wire-contract shape from kumiko-enterprise's `ai-text` feature instead of
7
+ // importing it — this package is public NPM, ai-text is a private Enterprise
8
+ // package, and the widgets only ever talk HTTP (ai-text-primitive plan doc,
9
+ // Architecture Decision 1). Keep these in lockstep with
10
+ // kumiko-enterprise/packages/ai-text/src/{modes,feature}.ts by hand; there's
11
+ // no compile-time link between the two repos.
12
+
13
+ export const AI_TEXT_RUN_QN = "ai-text:query:run";
14
+
15
+ export type AiTextMode = "complete" | "correct" | "translate" | "rewrite";
16
+ export type AiTextRewriteStyle = "formal" | "casual" | "concise" | "expand";
17
+
18
+ export type AiTextRunPayload =
19
+ | { readonly mode: "complete"; readonly text: string }
20
+ | { readonly mode: "correct"; readonly text: string }
21
+ | { readonly mode: "translate"; readonly text: string; readonly targetLanguage: string }
22
+ | { readonly mode: "rewrite"; readonly text: string; readonly style?: AiTextRewriteStyle };
23
+
24
+ export type AiTextUsage = { readonly inputTokens: number; readonly outputTokens: number };
25
+
26
+ export type AiTextRunResult =
27
+ | { readonly type: "text"; readonly text: string; readonly usage: AiTextUsage }
28
+ | { readonly type: "error"; readonly reason: string; readonly usage: AiTextUsage };
29
+
30
+ // =============================================================================
31
+ // useAiTextAction — one-shot request/response, any mode
32
+ // =============================================================================
33
+ //
34
+ // v1 has no streaming (ai-text-primitive plan doc, sequencing note —
35
+ // SSE-with-auth-reuse needs a framework-core `r.streamHandler` primitive,
36
+ // tracked separately). `complete` goes through this same request/response
37
+ // path as correct/translate/rewrite; `useCompletion` below adds debounce on
38
+ // top for the ghost-text use-case specifically.
39
+
40
+ export type AiTextActionState =
41
+ | "idle"
42
+ | "loading"
43
+ | "success"
44
+ | "error"
45
+ | "cap-exceeded"
46
+ | "unavailable";
47
+
48
+ export type UseAiTextActionResult = {
49
+ readonly run: (payload: AiTextRunPayload) => Promise<AiTextRunResult | null>;
50
+ readonly state: AiTextActionState;
51
+ readonly result: AiTextRunResult | null;
52
+ readonly error: DispatcherError | null;
53
+ readonly reset: () => void;
54
+ };
55
+
56
+ function stateForError(error: DispatcherError): AiTextActionState {
57
+ if (error.code === "cap_exceeded" || error.code === "rate_limited") return "cap-exceeded";
58
+ if (error.code === "feature_disabled") return "unavailable";
59
+ return "error";
60
+ }
61
+
62
+ export function useAiTextAction(): UseAiTextActionResult {
63
+ const dispatcher = useDispatcher();
64
+ const [state, setState] = useState<AiTextActionState>("idle");
65
+ const [result, setResult] = useState<AiTextRunResult | null>(null);
66
+ const [error, setError] = useState<DispatcherError | null>(null);
67
+
68
+ // Track the in-flight call so a newer `run()` cancels an older one, and
69
+ // unmount doesn't set state after the component is gone — same pattern
70
+ // as useQuery's activeCtrl.
71
+ const activeCtrl = useRef<AbortController | null>(null);
72
+ useEffect(() => {
73
+ return () => {
74
+ activeCtrl.current?.abort();
75
+ };
76
+ }, []);
77
+
78
+ const run = useCallback(
79
+ async (payload: AiTextRunPayload): Promise<AiTextRunResult | null> => {
80
+ activeCtrl.current?.abort();
81
+ const ctrl = new AbortController();
82
+ activeCtrl.current = ctrl;
83
+
84
+ setState("loading");
85
+ setError(null);
86
+
87
+ const res = await dispatcher.query<AiTextRunResult>(AI_TEXT_RUN_QN, payload, {
88
+ signal: ctrl.signal,
89
+ });
90
+ if (ctrl.signal.aborted) return null;
91
+
92
+ if (!res.isSuccess) {
93
+ if (res.error.code === "aborted") return null;
94
+ setError(res.error);
95
+ setState(stateForError(res.error));
96
+ return null;
97
+ }
98
+
99
+ setResult(res.data);
100
+ setState("success");
101
+ return res.data;
102
+ },
103
+ [dispatcher],
104
+ );
105
+
106
+ const reset = useCallback(() => {
107
+ activeCtrl.current?.abort();
108
+ setState("idle");
109
+ setResult(null);
110
+ setError(null);
111
+ }, []);
112
+
113
+ return { run, state, result, error, reset };
114
+ }
115
+
116
+ // =============================================================================
117
+ // useCompletion — debounced ghost-text for AiTextField/AiTextArea
118
+ // =============================================================================
119
+ //
120
+ // Debounce exists to keep the request-rate down against the monthly cap,
121
+ // not for UX polish — every keystroke would otherwise burn a request.
122
+
123
+ export type UseCompletionResult = {
124
+ readonly suggestion: string | null;
125
+ readonly state: AiTextActionState;
126
+ readonly error: DispatcherError | null;
127
+ /** Debounced — schedules a completion request `debounceMs` after the
128
+ * last call. Calling again before the timer fires replaces it. */
129
+ readonly requestCompletion: (text: string) => void;
130
+ /** Cancels any pending/in-flight request and clears the suggestion. */
131
+ readonly clear: () => void;
132
+ };
133
+
134
+ const DEFAULT_DEBOUNCE_MS = 500;
135
+
136
+ export function useCompletion(debounceMs: number = DEFAULT_DEBOUNCE_MS): UseCompletionResult {
137
+ const { run, state, result, error, reset } = useAiTextAction();
138
+ const timerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
139
+
140
+ const clearTimer = useCallback(() => {
141
+ if (timerRef.current !== undefined) {
142
+ clearTimeout(timerRef.current);
143
+ timerRef.current = undefined;
144
+ }
145
+ }, []);
146
+
147
+ const requestCompletion = useCallback(
148
+ (text: string) => {
149
+ clearTimer();
150
+ if (text.length === 0) {
151
+ reset();
152
+ return;
153
+ }
154
+ timerRef.current = setTimeout(() => {
155
+ void run({ mode: "complete", text });
156
+ }, debounceMs);
157
+ },
158
+ [clearTimer, reset, run, debounceMs],
159
+ );
160
+
161
+ const clear = useCallback(() => {
162
+ clearTimer();
163
+ reset();
164
+ }, [clearTimer, reset]);
165
+
166
+ useEffect(() => clearTimer, [clearTimer]);
167
+
168
+ const suggestion = result?.type === "text" ? result.text : null;
169
+
170
+ return { suggestion, state, error, requestCompletion, clear };
171
+ }
@@ -1,5 +1,5 @@
1
1
  import type { DispatcherError, WriteResult } from "@cosmicdrift/kumiko-headless";
2
- import { useCallback, useState } from "react";
2
+ import { useCallback, useRef, useState } from "react";
3
3
  import { useDispatcher } from "../context/dispatcher-context";
4
4
 
5
5
  // React wrapper around dispatcher.write — the write-side sibling of
@@ -24,24 +24,34 @@ export function useMutation<TData = unknown>(type: string): UseMutationResult<TD
24
24
  const [pending, setPending] = useState(false);
25
25
  const [error, setError] = useState<DispatcherError | null>(null);
26
26
  const [data, setData] = useState<TData | null>(null);
27
+ // Sequence guard: `mutate` has no abort (unlike useQuery's GETs, a write
28
+ // already landed server-side and can't be cancelled). Two overlapping
29
+ // calls on one instance (e.g. two list-row actions) must not let the
30
+ // first-to-resolve clobber pending/error/data set by the second — only
31
+ // the most recently STARTED call's outcome may update shared state.
32
+ const sequence = useRef(0);
27
33
 
28
34
  const mutate = useCallback(
29
35
  async (payload: unknown): Promise<WriteResult<TData>> => {
36
+ const callSeq = ++sequence.current;
30
37
  setPending(true);
31
38
  setError(null);
32
39
  const result = await dispatcher.write<TData>(type, payload);
33
- if (result.isSuccess) {
34
- setData(result.data);
35
- } else {
36
- setError(result.error);
40
+ if (callSeq === sequence.current) {
41
+ if (result.isSuccess) {
42
+ setData(result.data);
43
+ } else {
44
+ setError(result.error);
45
+ }
46
+ setPending(false);
37
47
  }
38
- setPending(false);
39
48
  return result;
40
49
  },
41
50
  [dispatcher, type],
42
51
  );
43
52
 
44
53
  const reset = useCallback(() => {
54
+ sequence.current++; // invalidate any in-flight mutate's late update
45
55
  setPending(false);
46
56
  setError(null);
47
57
  setData(null);
@@ -63,6 +63,20 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
63
63
  "kumiko.dialog.cancel": "Abbrechen",
64
64
  "kumiko.dialog.close": "Schließen",
65
65
 
66
+ // AiTextField/AiTextArea — Ghost-Text-Hint, Toolbar-Aria-Labels, Diff-Dialog.
67
+ "kumiko.aiText.acceptHint": "Tab = übernehmen, Esc = verwerfen",
68
+ "kumiko.aiText.correct": "Korrigieren",
69
+ "kumiko.aiText.translate": "Übersetzen",
70
+ "kumiko.aiText.rewrite": "Umschreiben",
71
+ "kumiko.aiText.diff.before": "Vorher",
72
+ "kumiko.aiText.diff.after": "Nachher",
73
+ "kumiko.aiText.diff.generating": "Wird generiert…",
74
+ "kumiko.aiText.style.formal": "Formell",
75
+ "kumiko.aiText.style.casual": "Locker",
76
+ "kumiko.aiText.style.concise": "Kompakt",
77
+ "kumiko.aiText.style.expand": "Ausführlicher",
78
+ "kumiko.aiText.capExceeded": "Monatliches AI-Limit erreicht.",
79
+
66
80
  // Row-Actions — Fehler-Toast wenn ein Action-Write fehlschlägt.
67
81
  "kumiko.rowAction.failed": "Aktion fehlgeschlagen",
68
82
 
@@ -128,7 +142,10 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
128
142
  "errors.unconfigured": "Diese Funktion ist noch nicht konfiguriert.",
129
143
  "errors.internal": "Etwas ist schiefgegangen. Bitte versuche es später erneut.",
130
144
  "errors.rate_limited": "Zu viele Anfragen. Bitte versuche es in Kürze erneut.",
145
+ "errors.cap.exceeded":
146
+ "Limit erreicht. Bitte Tarif upgraden oder auf die nächste Periode warten.",
131
147
  "errors.download.urlMissing": "Download nicht verfügbar — bitte versuche es erneut.",
148
+ "auth.errors.originNotAllowed": "Zugriff von dieser Herkunft ist nicht erlaubt.",
132
149
  },
133
150
  en: {
134
151
  "kumiko.actions.save": "Save",
@@ -171,6 +188,19 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
171
188
  "kumiko.dialog.cancel": "Cancel",
172
189
  "kumiko.dialog.close": "Close",
173
190
 
191
+ "kumiko.aiText.acceptHint": "Tab = accept, Esc = discard",
192
+ "kumiko.aiText.correct": "Correct",
193
+ "kumiko.aiText.translate": "Translate",
194
+ "kumiko.aiText.rewrite": "Rewrite",
195
+ "kumiko.aiText.diff.before": "Before",
196
+ "kumiko.aiText.diff.after": "After",
197
+ "kumiko.aiText.diff.generating": "Generating…",
198
+ "kumiko.aiText.style.formal": "Formal",
199
+ "kumiko.aiText.style.casual": "Casual",
200
+ "kumiko.aiText.style.concise": "Concise",
201
+ "kumiko.aiText.style.expand": "Expand",
202
+ "kumiko.aiText.capExceeded": "Monthly AI limit reached.",
203
+
174
204
  "kumiko.rowAction.failed": "Action failed",
175
205
 
176
206
  "kumiko.config.source.user": "My value",
@@ -221,6 +251,8 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
221
251
  "errors.unconfigured": "This feature isn't configured yet.",
222
252
  "errors.internal": "Something went wrong. Please try again later.",
223
253
  "errors.rate_limited": "Too many requests. Please try again shortly.",
254
+ "errors.cap.exceeded": "Limit reached. Upgrade your plan or wait for the next period.",
224
255
  "errors.download.urlMissing": "Download unavailable — please try again.",
256
+ "auth.errors.originNotAllowed": "Requests from this origin are not allowed.",
225
257
  },
226
258
  };
package/src/i18n.tsx CHANGED
@@ -40,9 +40,8 @@ export function translationsByLocaleFromKeys(source: TranslationsByKey): Transla
40
40
  const out: Record<string, Record<string, string>> = {};
41
41
  for (const [key, byLocale] of Object.entries(source)) {
42
42
  for (const [locale, value] of Object.entries(byLocale)) {
43
- const bucket = out[locale] ?? {};
44
- bucket[key] = value;
45
- out[locale] = bucket;
43
+ out[locale] ??= {};
44
+ out[locale][key] = value;
46
45
  }
47
46
  }
48
47
  return out;
package/src/index.ts CHANGED
@@ -59,11 +59,23 @@ export {
59
59
  useDispatcherStatus,
60
60
  useOptionalDispatcher,
61
61
  } from "./context/dispatcher-context";
62
+ export { formatWhen } from "./format-when";
62
63
  export {
63
64
  REFERENCE_COMBOBOX_LIMIT,
64
65
  REFERENCE_LIST_LOOKUP_LIMIT,
65
66
  REFERENCE_SEARCH_DEBOUNCE_MS,
66
67
  } from "./hooks/reference-limits";
68
+ export type {
69
+ AiTextActionState,
70
+ AiTextMode,
71
+ AiTextRewriteStyle,
72
+ AiTextRunPayload,
73
+ AiTextRunResult,
74
+ AiTextUsage,
75
+ UseAiTextActionResult,
76
+ UseCompletionResult,
77
+ } from "./hooks/use-ai-text";
78
+ export { AI_TEXT_RUN_QN, useAiTextAction, useCompletion } from "./hooks/use-ai-text";
67
79
  export type { UseDisclosureResult } from "./hooks/use-disclosure";
68
80
  export { useDisclosure } from "./hooks/use-disclosure";
69
81
  export type { UseFormOptions, UseFormResult } from "./hooks/use-form";
@@ -125,6 +137,7 @@ export type {
125
137
  TextProps,
126
138
  } from "./primitives";
127
139
  export { PrimitivesProvider, usePrimitives } from "./primitives";
140
+ export { sortByAccessor } from "./sort-by-accessor";
128
141
  export type { LiveEvent, LiveEventSubscriber, LiveEventsProviderProps } from "./sse/live-events";
129
142
  export { LiveEventsProvider, useLiveEvents } from "./sse/live-events";
130
143
  export type {
@@ -560,6 +560,10 @@ export type SectionProps = {
560
560
  * übernehmen"). Web rendert standalone eine abgehobene Footer-Row
561
561
  * (border-t), innerhalb eines Forms eine rechtsbündige Button-Reihe. */
562
562
  readonly actions?: ReactNode;
563
+ /** "destructive" marks the Section as a warning/danger area (e.g. account
564
+ * deletion, restrict processing) — border color only, no content change.
565
+ * Default "default" (normal card border). */
566
+ readonly variant?: "default" | "destructive";
563
567
  readonly testId?: string;
564
568
  };
565
569
 
@@ -0,0 +1,20 @@
1
+ import type { DataTableSort } from "./primitives";
2
+
3
+ /** Sorts `rows` by a `DataTableSort` against a field->accessor map. Unknown
4
+ * field or `sort === null` returns `rows` unchanged (no-op, not an error —
5
+ * callers pass whatever the DataTable reports). */
6
+ export function sortByAccessor<TRow>(
7
+ rows: readonly TRow[],
8
+ sort: DataTableSort | null,
9
+ accessors: Readonly<Record<string, (row: TRow) => string | number>>,
10
+ ): readonly TRow[] {
11
+ if (sort === null) return rows;
12
+ const accessor = accessors[sort.field];
13
+ if (accessor === undefined) return rows;
14
+ const factor = sort.dir === "asc" ? 1 : -1;
15
+ return [...rows].sort((a, b) => {
16
+ const av = accessor(a);
17
+ const bv = accessor(b);
18
+ return av < bv ? -factor : av > bv ? factor : 0;
19
+ });
20
+ }