@cosmicdrift/kumiko-renderer-web 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-web",
3
- "version": "0.146.4",
3
+ "version": "0.147.0",
4
4
  "description": "Web-platform bindings for @cosmicdrift/kumiko-renderer. HTML default-primitives, browser history-based navigation, EventSource-backed live events, and a one-call createKumikoApp that mounts the whole stack via react-dom.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -16,9 +16,9 @@
16
16
  "./styles.css": "./src/styles.css"
17
17
  },
18
18
  "dependencies": {
19
- "@cosmicdrift/kumiko-dispatcher-live": "0.146.4",
20
- "@cosmicdrift/kumiko-headless": "0.146.4",
21
- "@cosmicdrift/kumiko-renderer": "0.146.4",
19
+ "@cosmicdrift/kumiko-dispatcher-live": "0.147.0",
20
+ "@cosmicdrift/kumiko-headless": "0.147.0",
21
+ "@cosmicdrift/kumiko-renderer": "0.147.0",
22
22
  "@radix-ui/react-dialog": "^1.1.15",
23
23
  "@radix-ui/react-dropdown-menu": "^2.1.16",
24
24
  "@radix-ui/react-label": "^2.1.8",
@@ -326,6 +326,10 @@ describe("KumikoScreen dashboard — neue Panel-Kinds", () => {
326
326
  );
327
327
  await waitFor(() => expect(screen.getByText("92.753 €")).toBeTruthy());
328
328
  expect(screen.queryByTestId("kpi-icon")).toBeNull();
329
+ // #931: unregistriertes Icon darf auch keinen leeren, akzentgefärbten
330
+ // Chip-Wrapper rendern (StatCard gated den Span auf icon !== undefined).
331
+ const card = screen.getByTestId("dashboard-panel-kpi");
332
+ expect(card.querySelector('[style*="123456"]')).toBeNull();
329
333
  });
330
334
 
331
335
  test("stat-Panel: fehlende deltaDirection unterdrückt den Chip (kein Crash)", async () => {
@@ -120,6 +120,15 @@ describe("DefaultSection Card-Standard (subtitle + actions-Footer)", () => {
120
120
  expect(actions.textContent).toContain("Übernehmen");
121
121
  });
122
122
 
123
+ test('standalone: variant="destructive" adds a destructive border, default variant does not', () => {
124
+ render(
125
+ <Section testId="danger" title="Danger zone" variant="destructive">
126
+ <div>x</div>
127
+ </Section>,
128
+ );
129
+ expect(screen.getByTestId("danger").className).toContain("border-destructive/40");
130
+ });
131
+
123
132
  test("title-only standalone (Bestands-Consumer) hat keinen Divider mehr", () => {
124
133
  render(
125
134
  <Section testId="legacy" title="Stammdaten">
@@ -86,4 +86,46 @@ describe("projectionList writeHandler-Actions", () => {
86
86
  expect(write.mock.calls[0]?.[0]).toBe("status:write:maintenance:sync");
87
87
  expect(write.mock.calls[0]?.[1]).toEqual({ source: "manual" });
88
88
  });
89
+
90
+ // Prod-Bug 2026-06-07 (siehe useRowActionTrigger): ein verschluckter
91
+ // Write-Fehler sah für den User wie "nichts passiert" aus. Row- UND
92
+ // Toolbar-Action auf projectionList müssen denselben Surfacing-Pfad wie
93
+ // entityList nehmen (Toast statt stiller no-op).
94
+ test("Row-Action-Fehler wird als Toast surfaced, nicht verschluckt", async () => {
95
+ const write = mock(async (_type: string, _payload: unknown) => ({
96
+ isSuccess: false,
97
+ error: { code: "internal_error", httpStatus: 500, message: "maintenance start failed" },
98
+ }));
99
+ const { ToastProvider } = await import("../primitives/toast");
100
+ render(
101
+ <ToastProvider>
102
+ <DispatcherProvider dispatcher={makeDispatcher(write as unknown as Dispatcher["write"])}>
103
+ <KumikoScreen schema={schema} qn="status:screen:maintenance-list" />
104
+ </DispatcherProvider>
105
+ </ToastProvider>,
106
+ );
107
+ await waitFor(() => expect(screen.getByText("DB-Upgrade")).toBeTruthy());
108
+
109
+ fireEvent.click(screen.getByRole("button", { name: "status:action:start" }));
110
+ expect(await screen.findByText("maintenance start failed")).toBeTruthy();
111
+ });
112
+
113
+ test("Toolbar-Action-Fehler wird als Toast surfaced, nicht verschluckt", async () => {
114
+ const write = mock(async (_type: string, _payload: unknown) => ({
115
+ isSuccess: false,
116
+ error: { code: "internal_error", httpStatus: 500, message: "maintenance sync failed" },
117
+ }));
118
+ const { ToastProvider } = await import("../primitives/toast");
119
+ render(
120
+ <ToastProvider>
121
+ <DispatcherProvider dispatcher={makeDispatcher(write as unknown as Dispatcher["write"])}>
122
+ <KumikoScreen schema={schema} qn="status:screen:maintenance-list" />
123
+ </DispatcherProvider>
124
+ </ToastProvider>,
125
+ );
126
+ await waitFor(() => expect(screen.getByText("DB-Upgrade")).toBeTruthy());
127
+
128
+ fireEvent.click(screen.getByRole("button", { name: "status:action:sync" }));
129
+ expect(await screen.findByText("maintenance sync failed")).toBeTruthy();
130
+ });
89
131
  });
@@ -0,0 +1,86 @@
1
+ import { describe, expect, mock, test } from "bun:test";
2
+ import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
3
+ import { DispatcherProvider, useMutation } from "@cosmicdrift/kumiko-renderer";
4
+ import type { ReactNode } from "react";
5
+ import { act, createMockDispatcher, renderHook, waitFor } from "./test-utils";
6
+
7
+ function wrap(dispatcher: Dispatcher) {
8
+ return ({ children }: { children: ReactNode }) => (
9
+ <DispatcherProvider dispatcher={dispatcher}>{children}</DispatcherProvider>
10
+ );
11
+ }
12
+
13
+ describe("useMutation", () => {
14
+ // #902/2: two overlapping calls on one instance (list-row actions sharing
15
+ // it) must not let the earlier-STARTED, later-RESOLVING call clobber the
16
+ // state set by the later-started, earlier-resolving one.
17
+ test("out-of-order resolution: state reflects the most recently started call, not the most recently resolved one", async () => {
18
+ let resolveFirst: (() => void) | undefined;
19
+ const write = mock(async (_type: string, payload: unknown) => {
20
+ if ((payload as { id: string }).id === "row-1") {
21
+ await new Promise<void>((resolve) => {
22
+ resolveFirst = resolve;
23
+ });
24
+ return { isSuccess: true, data: { id: "row-1" } };
25
+ }
26
+ return { isSuccess: true, data: { id: "row-2" } };
27
+ });
28
+ const dispatcher = createMockDispatcher({ write: write as unknown as Dispatcher["write"] });
29
+ const { result } = renderHook(() => useMutation("row:update"), {
30
+ wrapper: wrap(dispatcher),
31
+ });
32
+
33
+ let firstCallDone: Promise<unknown>;
34
+ let secondCallDone: Promise<unknown>;
35
+ act(() => {
36
+ firstCallDone = result.current.mutate({ id: "row-1" });
37
+ });
38
+ await act(async () => {
39
+ secondCallDone = result.current.mutate({ id: "row-2" });
40
+ // let the second (no-delay) call resolve before the first
41
+ await Promise.resolve();
42
+ });
43
+
44
+ await waitFor(() => expect(result.current.data).toEqual({ id: "row-2" }));
45
+ expect(result.current.pending).toBe(false); // row-2 (the latest call) already settled
46
+
47
+ await act(async () => {
48
+ resolveFirst?.();
49
+ await firstCallDone;
50
+ await secondCallDone;
51
+ });
52
+
53
+ // The first call resolving late must not overwrite row-2's state.
54
+ expect(result.current.data).toEqual({ id: "row-2" });
55
+ expect(result.current.pending).toBe(false);
56
+ });
57
+
58
+ test("reset() invalidates a still-pending call's late state update", async () => {
59
+ let resolveWrite: (() => void) | undefined;
60
+ const write = mock(async () => {
61
+ await new Promise<void>((resolve) => {
62
+ resolveWrite = resolve;
63
+ });
64
+ return { isSuccess: true, data: { id: "late" } };
65
+ });
66
+ const dispatcher = createMockDispatcher({ write: write as unknown as Dispatcher["write"] });
67
+ const { result } = renderHook(() => useMutation("row:update"), {
68
+ wrapper: wrap(dispatcher),
69
+ });
70
+
71
+ let mutateDone: Promise<unknown>;
72
+ act(() => {
73
+ mutateDone = result.current.mutate({ id: "x" });
74
+ });
75
+ act(() => {
76
+ result.current.reset();
77
+ });
78
+ await act(async () => {
79
+ resolveWrite?.();
80
+ await mutateDone;
81
+ });
82
+
83
+ expect(result.current.data).toBeNull();
84
+ expect(result.current.pending).toBe(false);
85
+ });
86
+ });
@@ -9,7 +9,7 @@
9
9
  // anzeigefertige Werte (der Query-Handler formatiert).
10
10
  // deltaField/deltaDirectionField(+deltaToneField) sind
11
11
  // optional — nur wenn BEIDE Felder gesetzt sind UND geliefert
12
- // werden, zeigt die Kachel einen Delta-Chip ("↓ 23 %").
12
+ // werden, zeigt die Kachel einen Delta-Chip ("↓23 %").
13
13
  // icon/accentColor sind statisch am Panel (keine Query-
14
14
  // Felder) — icon über extensionSectionComponents wie bei
15
15
  // custom-Panels, accentColor ein roher CSS-Farbwert.
@@ -61,43 +61,34 @@ const STAT_TONES: ReadonlySet<string> = new Set(["default", "positive", "warn"])
61
61
  const WIDE_PANEL = "sm:col-span-2 lg:col-span-4";
62
62
  const HALF_PANEL = "sm:col-span-2 lg:col-span-2";
63
63
 
64
- function StatPanelIcon({
64
+ function StatPanelBody({
65
65
  panel,
66
+ label,
66
67
  screenId,
67
68
  filterParams,
68
69
  }: {
69
70
  readonly panel: DashboardStatPanel;
71
+ readonly label: string;
70
72
  readonly screenId: string;
71
73
  readonly filterParams: Readonly<Record<string, unknown>>;
72
74
  }): ReactNode {
73
- const name = panel.icon !== undefined ? extensionSectionName(panel.icon) : undefined;
74
- const Icon = useExtensionSectionComponent(name);
75
+ // Resolved HERE (not in a separate always-rendered child) so `icon` on
76
+ // <StatCard> is `undefined` — not a React element that renders empty —
77
+ // when the icon name isn't registered. StatCard gates its accent chip on
78
+ // `icon !== undefined`, so a resolved-but-hidden element used to leave a
79
+ // stray accent-colored chip next to the label.
80
+ const iconName = panel.icon !== undefined ? extensionSectionName(panel.icon) : undefined;
81
+ const Icon = useExtensionSectionComponent(iconName);
75
82
  useEffect(() => {
76
- if (panel.icon !== undefined && name !== undefined && Icon === undefined) {
83
+ if (panel.icon !== undefined && iconName !== undefined && Icon === undefined) {
77
84
  // biome-ignore lint/suspicious/noConsole: dev-warning für Setup-Fehler
78
85
  console.warn(
79
86
  `[kumiko] Dashboard stat-panel "${panel.id}" on screen "${screenId}" references icon ` +
80
- `"${name}", which is not registered in clientFeatures.extensionSectionComponents.`,
87
+ `"${iconName}", which is not registered in clientFeatures.extensionSectionComponents.`,
81
88
  );
82
89
  }
83
- }, [panel.icon, panel.id, name, Icon, screenId]);
84
- if (Icon === undefined) return null;
85
- return (
86
- <Icon entityName={screenId} entityId={null} screenId={screenId} filterParams={filterParams} />
87
- );
88
- }
90
+ }, [panel.icon, panel.id, iconName, Icon, screenId]);
89
91
 
90
- function StatPanelBody({
91
- panel,
92
- label,
93
- screenId,
94
- filterParams,
95
- }: {
96
- readonly panel: DashboardStatPanel;
97
- readonly label: string;
98
- readonly screenId: string;
99
- readonly filterParams: Readonly<Record<string, unknown>>;
100
- }): ReactNode {
101
92
  const { data, error, loading, refetch } = useQuery<Readonly<Record<string, unknown>>>(
102
93
  panel.query,
103
94
  filterParams,
@@ -114,14 +105,19 @@ function StatPanelBody({
114
105
  return (
115
106
  <StatCard
116
107
  icon={
117
- panel.icon !== undefined ? (
118
- <StatPanelIcon panel={panel} screenId={screenId} filterParams={filterParams} />
108
+ Icon !== undefined ? (
109
+ <Icon
110
+ entityName={screenId}
111
+ entityId={null}
112
+ screenId={screenId}
113
+ filterParams={filterParams}
114
+ />
119
115
  ) : undefined
120
116
  }
121
117
  label={label}
122
118
  value={String(record[panel.valueField] ?? "—")}
123
119
  tone={tone}
124
- accentColor={panel.accentColor}
120
+ {...(Icon !== undefined && { accentColor: panel.accentColor })}
125
121
  {...(sub !== undefined && sub !== null && { sub: String(sub) })}
126
122
  {...(delta !== undefined && { delta })}
127
123
  testId={`dashboard-panel-${panel.id}`}
package/src/index.ts CHANGED
@@ -155,6 +155,8 @@ export {
155
155
  } from "./tokens";
156
156
  export { SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarProvider } from "./ui/sidebar";
157
157
  export type {
158
+ AiTextAreaProps,
159
+ AiTextFieldProps,
158
160
  BooleanFieldProps,
159
161
  ComparisonMetric,
160
162
  DateFieldProps,
@@ -176,6 +178,8 @@ export type {
176
178
  TimeseriesPoint,
177
179
  } from "./widgets";
178
180
  export {
181
+ AiTextArea,
182
+ AiTextField,
179
183
  BooleanField,
180
184
  CollapsibleSection,
181
185
  ComparisonTable,
@@ -619,11 +619,11 @@ function NavMenuNode({ node, collapsed, onToggle }: NavSubProps): ReactNode {
619
619
 
620
620
  const chevron = s.expandable ? (
621
621
  <SidebarMenuAction
622
- aria-label={t(s.isCollapsed ? "kumiko.nav.expand" : "kumiko.nav.collapse")}
623
- aria-expanded={!s.isCollapsed}
622
+ aria-label={t(s.isExpanded ? "kumiko.nav.collapse" : "kumiko.nav.expand")}
623
+ aria-expanded={s.isExpanded}
624
624
  onClick={() => onToggle(node.qualifiedName)}
625
625
  >
626
- {s.isCollapsed ? <ChevronRight /> : <ChevronDown />}
626
+ {s.isExpanded ? <ChevronDown /> : <ChevronRight />}
627
627
  </SidebarMenuAction>
628
628
  ) : null;
629
629
 
@@ -675,15 +675,15 @@ function NavMenuNode({ node, collapsed, onToggle }: NavSubProps): ReactNode {
675
675
  onClick={() => {
676
676
  if (s.expandable) onToggle(node.qualifiedName);
677
677
  }}
678
- {...(s.expandable && { "aria-expanded": !s.isCollapsed })}
678
+ {...(s.expandable && { "aria-expanded": s.isExpanded })}
679
679
  >
680
680
  <NavLeadingIcon node={node} active={false} expanded={s.isExpanded} />
681
681
  <span className="truncate">{s.displayLabel}</span>
682
682
  {s.expandable &&
683
- (s.isCollapsed ? (
684
- <ChevronRight className="ml-auto" />
685
- ) : (
683
+ (s.isExpanded ? (
686
684
  <ChevronDown className="ml-auto" />
685
+ ) : (
686
+ <ChevronRight className="ml-auto" />
687
687
  ))}
688
688
  </SidebarMenuButton>
689
689
  <NodeActions node={node} />
@@ -724,15 +724,15 @@ function NavSubNode({ node, collapsed, onToggle }: NavSubProps): ReactNode {
724
724
  const chevron = s.expandable ? (
725
725
  <button
726
726
  type="button"
727
- aria-label={t(s.isCollapsed ? "kumiko.nav.expand" : "kumiko.nav.collapse")}
728
- aria-expanded={!s.isCollapsed}
727
+ aria-label={t(s.isExpanded ? "kumiko.nav.collapse" : "kumiko.nav.expand")}
728
+ aria-expanded={s.isExpanded}
729
729
  onClick={(e) => {
730
730
  e.stopPropagation();
731
731
  onToggle(node.qualifiedName);
732
732
  }}
733
733
  className="absolute top-1 right-1 flex size-5 items-center justify-center rounded-md text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
734
734
  >
735
- {s.isCollapsed ? <ChevronRight className="size-3.5" /> : <ChevronDown className="size-3.5" />}
735
+ {s.isExpanded ? <ChevronDown className="size-3.5" /> : <ChevronRight className="size-3.5" />}
736
736
  </button>
737
737
  ) : null;
738
738
 
@@ -144,7 +144,7 @@ function DefaultButton({
144
144
  data-loading={loading === true ? "true" : undefined}
145
145
  variant={BUTTON_VARIANT[variant]}
146
146
  size={BUTTON_SIZE[size]}
147
- {...(ariaLabel !== undefined && { "aria-label": ariaLabel })}
147
+ aria-label={ariaLabel}
148
148
  className={className}
149
149
  >
150
150
  {loading === true ? <Loader2 className="size-4 animate-spin" aria-hidden="true" /> : children}
@@ -1509,7 +1509,14 @@ export function FormScreenShell({
1509
1509
  );
1510
1510
  }
1511
1511
 
1512
- function DefaultSection({ title, subtitle, children, actions, testId }: SectionProps): ReactNode {
1512
+ function DefaultSection({
1513
+ title,
1514
+ subtitle,
1515
+ children,
1516
+ actions,
1517
+ variant = "default",
1518
+ testId,
1519
+ }: SectionProps): ReactNode {
1513
1520
  const insideForm = useContext(InsideFormContext);
1514
1521
 
1515
1522
  // h3 statt CardTitle (= div): erhält die Heading-Semantik für
@@ -1542,7 +1549,13 @@ function DefaultSection({ title, subtitle, children, actions, testId }: SectionP
1542
1549
  // actions hier = rechtsbündige Reihe (das Form trägt den eigenen Footer).
1543
1550
  if (insideForm) {
1544
1551
  return (
1545
- <section data-testid={testId} className="flex flex-col gap-4 px-6 py-6">
1552
+ <section
1553
+ data-testid={testId}
1554
+ className={cn(
1555
+ "flex flex-col gap-4 px-6 py-6",
1556
+ variant === "destructive" && "border-l-2 border-destructive/40",
1557
+ )}
1558
+ >
1546
1559
  {header}
1547
1560
  {children}
1548
1561
  {actions !== undefined && (
@@ -1565,7 +1578,14 @@ function DefaultSection({ title, subtitle, children, actions, testId }: SectionP
1565
1578
  // `children`) WOULD get silently clipped — verify this against any new
1566
1579
  // standalone-section content that renders its own non-portaled overlay.
1567
1580
  return (
1568
- <div data-testid={testId} className={cn(cardSurface(), "overflow-hidden")}>
1581
+ <div
1582
+ data-testid={testId}
1583
+ className={cn(
1584
+ cardSurface(),
1585
+ "overflow-hidden",
1586
+ variant === "destructive" && "border-destructive/40",
1587
+ )}
1588
+ >
1569
1589
  <div className="flex flex-col gap-4 px-6 py-6">
1570
1590
  {header}
1571
1591
  {children}
@@ -0,0 +1,267 @@
1
+ import { describe, expect, mock, test } from "bun:test";
2
+ import { DispatcherProvider } from "@cosmicdrift/kumiko-renderer";
3
+ import { type ReactElement, useState } from "react";
4
+ import {
5
+ createMockDispatcher,
6
+ fireEvent,
7
+ render,
8
+ screen,
9
+ waitFor,
10
+ } from "../../__tests__/test-utils";
11
+ import { AiTextArea, AiTextField, type AiTextFieldProps } from "../ai-text-field";
12
+
13
+ function renderWithDispatcher(
14
+ ui: ReactElement,
15
+ query: NonNullable<Parameters<typeof createMockDispatcher>[0]>["query"],
16
+ ) {
17
+ const dispatcher = createMockDispatcher({ query });
18
+ return render(<DispatcherProvider dispatcher={dispatcher}>{ui}</DispatcherProvider>);
19
+ }
20
+
21
+ // Real controlled-component loop: onChange feeds back into `value` so
22
+ // Tab-accept sees the actually-typed text, not a stale prop. Plain
23
+ // `mock()`-only setups (no state) can't exercise this — that's a test
24
+ // bug we hit and fixed once already (see git history), keep it this way.
25
+ function ControlledAiTextField(
26
+ props: Omit<AiTextFieldProps, "value" | "onChange"> & {
27
+ readonly initialValue: string;
28
+ readonly onChangeSpy?: (v: string) => void;
29
+ },
30
+ ) {
31
+ const { initialValue, onChangeSpy, ...rest } = props;
32
+ const [value, setValue] = useState(initialValue);
33
+ return (
34
+ <AiTextField
35
+ {...rest}
36
+ value={value}
37
+ onChange={(v) => {
38
+ setValue(v);
39
+ onChangeSpy?.(v);
40
+ }}
41
+ />
42
+ );
43
+ }
44
+
45
+ describe("AiTextField", () => {
46
+ test("plain typing calls onChange like a normal text field", () => {
47
+ const onChangeSpy = mock((_v: string) => {});
48
+ renderWithDispatcher(
49
+ <ControlledAiTextField
50
+ label="Title"
51
+ id="title"
52
+ name="title"
53
+ initialValue=""
54
+ onChangeSpy={onChangeSpy}
55
+ actions={[]}
56
+ completion={false}
57
+ testId="title"
58
+ />,
59
+ (async () => ({
60
+ isSuccess: true,
61
+ data: { type: "text", text: "", usage: { inputTokens: 0, outputTokens: 0 } },
62
+ })) as never,
63
+ );
64
+ fireEvent.change(screen.getByTestId("title-input"), { target: { value: "hi" } });
65
+ expect(onChangeSpy).toHaveBeenCalledWith("hi");
66
+ });
67
+
68
+ test("ghost-text: shows suggestion, Tab accepts it appended to the typed text", async () => {
69
+ const onChangeSpy = mock((_v: string) => {});
70
+ renderWithDispatcher(
71
+ <ControlledAiTextField
72
+ label="Title"
73
+ id="title"
74
+ name="title"
75
+ initialValue=""
76
+ onChangeSpy={onChangeSpy}
77
+ actions={[]}
78
+ completionDebounceMs={5}
79
+ testId="title"
80
+ />,
81
+ (async () => ({
82
+ isSuccess: true,
83
+ data: { type: "text", text: "fox", usage: { inputTokens: 1, outputTokens: 1 } },
84
+ })) as never,
85
+ );
86
+
87
+ const input = screen.getByTestId("title-input");
88
+ fireEvent.change(input, { target: { value: "the quick brown " } });
89
+
90
+ await waitFor(() => expect(screen.queryByText("fox")).toBeTruthy(), { timeout: 1000 });
91
+
92
+ fireEvent.keyDown(input, { key: "Tab" });
93
+ expect(onChangeSpy).toHaveBeenLastCalledWith("the quick brown fox");
94
+ });
95
+
96
+ test("ghost-text: Esc discards the suggestion without calling onChange", async () => {
97
+ const onChangeSpy = mock((_v: string) => {});
98
+ renderWithDispatcher(
99
+ <ControlledAiTextField
100
+ label="Title"
101
+ id="title"
102
+ name="title"
103
+ initialValue=""
104
+ onChangeSpy={onChangeSpy}
105
+ actions={[]}
106
+ completionDebounceMs={5}
107
+ testId="title"
108
+ />,
109
+ (async () => ({
110
+ isSuccess: true,
111
+ data: { type: "text", text: "there", usage: { inputTokens: 1, outputTokens: 1 } },
112
+ })) as never,
113
+ );
114
+
115
+ const input = screen.getByTestId("title-input");
116
+ fireEvent.change(input, { target: { value: "hi " } });
117
+ await waitFor(() => expect(screen.queryByText("there")).toBeTruthy(), { timeout: 1000 });
118
+
119
+ onChangeSpy.mockClear();
120
+ fireEvent.keyDown(input, { key: "Escape" });
121
+ expect(screen.queryByText("there")).toBeNull();
122
+ expect(onChangeSpy).not.toHaveBeenCalled();
123
+ });
124
+
125
+ test("feature_disabled → toolbar hides, field stays usable (graceful degradation)", async () => {
126
+ const onChangeSpy = mock((_v: string) => {});
127
+ renderWithDispatcher(
128
+ <ControlledAiTextField
129
+ label="Title"
130
+ id="title"
131
+ name="title"
132
+ initialValue=""
133
+ onChangeSpy={onChangeSpy}
134
+ completionDebounceMs={5}
135
+ testId="title"
136
+ />,
137
+ (async () => ({
138
+ isSuccess: false,
139
+ error: { code: "feature_disabled", message: "off", i18nKey: "errors.feature.disabled" },
140
+ })) as never,
141
+ );
142
+
143
+ fireEvent.change(screen.getByTestId("title-input"), { target: { value: "hi " } });
144
+ await waitFor(() => expect(screen.queryByRole("button")).toBeNull(), { timeout: 1000 });
145
+ expect(screen.getByTestId("title-input")).toBeTruthy();
146
+ });
147
+
148
+ test("correct action: opens dialog, runs immediately, apply calls onChange with the result", async () => {
149
+ const onChangeSpy = mock((_v: string) => {});
150
+ renderWithDispatcher(
151
+ <ControlledAiTextField
152
+ label="Title"
153
+ id="title"
154
+ name="title"
155
+ initialValue="teh cat sat"
156
+ onChangeSpy={onChangeSpy}
157
+ completion={false}
158
+ actions={["correct"]}
159
+ testId="title"
160
+ />,
161
+ (async (_type: string, payload: unknown) => {
162
+ const mode = (payload as { mode: string }).mode;
163
+ if (mode !== "correct") throw new Error(`unexpected mode ${mode}`);
164
+ return {
165
+ isSuccess: true,
166
+ data: { type: "text", text: "the cat sat", usage: { inputTokens: 3, outputTokens: 3 } },
167
+ };
168
+ }) as never,
169
+ );
170
+
171
+ fireEvent.click(screen.getByRole("button", { name: "Correct" }));
172
+ await waitFor(() => expect(screen.queryByText("the cat sat")).toBeTruthy());
173
+
174
+ fireEvent.click(screen.getByRole("button", { name: "Confirm" }));
175
+ expect(onChangeSpy).toHaveBeenCalledWith("the cat sat");
176
+ });
177
+ });
178
+
179
+ describe("AiTextField ghost-text overlay — scroll sync", () => {
180
+ test("Tab/Escape aside: overlay mirrors the input's scrollLeft on an interactive scroll", async () => {
181
+ renderWithDispatcher(
182
+ <ControlledAiTextField
183
+ label="Title"
184
+ id="title"
185
+ name="title"
186
+ initialValue=""
187
+ actions={[]}
188
+ completionDebounceMs={5}
189
+ testId="title"
190
+ />,
191
+ (async () => ({
192
+ isSuccess: true,
193
+ data: { type: "text", text: "fox", usage: { inputTokens: 1, outputTokens: 1 } },
194
+ })) as never,
195
+ );
196
+
197
+ const input = screen.getByTestId("title-input") as HTMLInputElement;
198
+ fireEvent.change(input, { target: { value: "the quick brown " } });
199
+ await waitFor(() => expect(screen.queryByText("fox")).toBeTruthy(), { timeout: 1000 });
200
+
201
+ const overlay = input.parentElement?.querySelector('[aria-hidden="true"]') as HTMLDivElement;
202
+ expect(overlay).toBeTruthy();
203
+
204
+ // Interactive scroll (e.g. arrow-key caret move) — caught by the
205
+ // onScroll handler.
206
+ Object.defineProperty(input, "scrollLeft", { value: 50, writable: true });
207
+ fireEvent.scroll(input);
208
+ expect(overlay.scrollLeft).toBe(50);
209
+ });
210
+
211
+ test("overlay resyncs on typing even without a `scroll` event (Chrome doesn't fire one for caret-follow auto-scroll)", async () => {
212
+ renderWithDispatcher(
213
+ <ControlledAiTextField
214
+ label="Title"
215
+ id="title"
216
+ name="title"
217
+ initialValue=""
218
+ actions={[]}
219
+ completionDebounceMs={5}
220
+ testId="title"
221
+ />,
222
+ (async () => ({
223
+ isSuccess: true,
224
+ data: { type: "text", text: "fox", usage: { inputTokens: 1, outputTokens: 1 } },
225
+ })) as never,
226
+ );
227
+
228
+ const input = screen.getByTestId("title-input") as HTMLInputElement;
229
+ fireEvent.change(input, { target: { value: "the quick brown " } });
230
+ await waitFor(() => expect(screen.queryByText("fox")).toBeTruthy(), { timeout: 1000 });
231
+
232
+ const overlay = input.parentElement?.querySelector('[aria-hidden="true"]') as HTMLDivElement;
233
+
234
+ // Simulate the browser having auto-scrolled the input to reveal the
235
+ // caret WITHOUT firing a `scroll` event — the real Chrome behavior this
236
+ // bug hinged on. Only the value/suggestion-keyed layout effect (not
237
+ // onScroll) can catch this.
238
+ Object.defineProperty(input, "scrollLeft", { value: 120, writable: true });
239
+ fireEvent.change(input, { target: { value: "the quick brown f" } });
240
+ await waitFor(() => expect(screen.queryByText("fox")).toBeTruthy(), { timeout: 1000 });
241
+
242
+ expect(overlay.scrollLeft).toBe(120);
243
+ });
244
+ });
245
+
246
+ describe("AiTextArea", () => {
247
+ test("renders a textarea with the given rows", () => {
248
+ const onChange = mock((_v: string) => {});
249
+ renderWithDispatcher(
250
+ <AiTextArea
251
+ label="Body"
252
+ id="body"
253
+ name="body"
254
+ value=""
255
+ onChange={onChange}
256
+ rows={6}
257
+ actions={[]}
258
+ completion={false}
259
+ testId="body"
260
+ />,
261
+ (async () => ({ isSuccess: true, data: {} })) as never,
262
+ );
263
+ const el = screen.getByTestId("body-input") as HTMLTextAreaElement;
264
+ expect(el.tagName).toBe("TEXTAREA");
265
+ expect(el.getAttribute("rows")).toBe("6");
266
+ });
267
+ });