@cosmicdrift/kumiko-renderer 0.130.1 → 0.131.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.130.1",
3
+ "version": "0.131.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.130.1",
19
- "@cosmicdrift/kumiko-headless": "0.130.1",
18
+ "@cosmicdrift/kumiko-framework": "0.131.0",
19
+ "@cosmicdrift/kumiko-headless": "0.131.0",
20
20
  "react": "^19.2.6"
21
21
  },
22
22
  "devDependencies": {
@@ -0,0 +1,32 @@
1
+ // Dashboard-Body-Injection: der KumikoScreen-Switch ist plattform-agnostisch,
2
+ // die Dashboard-Panels (StatCard, Charts) sind es nicht — die Implementierung
3
+ // kommt vom Platform-Package (renderer-web registriert seine Web-Variante in
4
+ // createKumikoApp). Gleiches Muster wie CustomScreensProvider.
5
+
6
+ import type { DashboardScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
7
+ import type { Translate } from "@cosmicdrift/kumiko-headless";
8
+ import { type ComponentType, createContext, type ReactNode, useContext } from "react";
9
+
10
+ export type DashboardBodyProps = {
11
+ readonly screen: DashboardScreenDefinition;
12
+ readonly translate?: Translate;
13
+ };
14
+
15
+ const DashboardBodyContext = createContext<ComponentType<DashboardBodyProps> | undefined>(
16
+ undefined,
17
+ );
18
+
19
+ export type DashboardBodyProviderProps = {
20
+ readonly children: ReactNode;
21
+ readonly value: ComponentType<DashboardBodyProps>;
22
+ };
23
+
24
+ export function DashboardBodyProvider({ children, value }: DashboardBodyProviderProps): ReactNode {
25
+ return <DashboardBodyContext.Provider value={value}>{children}</DashboardBodyContext.Provider>;
26
+ }
27
+
28
+ /** Undefined wenn kein Platform-Package einen Dashboard-Body registriert
29
+ * hat — KumikoScreen zeigt dann seinen Placeholder-Banner. */
30
+ export function useDashboardBody(): ComponentType<DashboardBodyProps> | undefined {
31
+ return useContext(DashboardBodyContext);
32
+ }
@@ -2,6 +2,7 @@ import type { ConfigCascade } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import type {
3
3
  ActionFormScreenDefinition,
4
4
  ConfigEditScreenDefinition,
5
+ DashboardScreenDefinition,
5
6
  EntityDefinition,
6
7
  EntityEditScreenDefinition,
7
8
  EntityListScreenDefinition,
@@ -34,6 +35,7 @@ import { type DataTableFacet, type DataTableRowAction, usePrimitives } from "../
34
35
  import { synthesizeActionFormEntity, synthesizeActionFormScreen } from "./action-form-shim";
35
36
  import { synthesizeConfigEditEntity, synthesizeConfigEditScreen } from "./config-edit-shim";
36
37
  import { useCustomScreenComponent } from "./custom-screens";
38
+ import { useDashboardBody } from "./dashboard-body";
37
39
  import type { FeatureSchema } from "./feature-schema";
38
40
  import { useNav } from "./nav";
39
41
  import { synthesizeProjectionEntity, synthesizeProjectionScreen } from "./projection-list-shim";
@@ -146,6 +148,8 @@ export function KumikoScreen({
146
148
  {...(onRowClick !== undefined && { onRowClick })}
147
149
  />
148
150
  );
151
+ case "dashboard":
152
+ return <DashboardScreenBody screen={screen} translate={translate} />;
149
153
  case "actionForm":
150
154
  return <ActionFormBody schema={schema} screen={screen} translate={translate} />;
151
155
  case "configEdit":
@@ -155,6 +159,29 @@ export function KumikoScreen({
155
159
  }
156
160
  }
157
161
 
162
+ // Injection-Body für dashboard-Screens: die Panel-Implementierung (StatCard,
163
+ // Charts) ist plattform-spezifisch und kommt aus dem DashboardBody-Context
164
+ // (renderer-web registriert die Web-Variante in createKumikoApp).
165
+ function DashboardScreenBody({
166
+ screen,
167
+ translate,
168
+ }: {
169
+ readonly screen: DashboardScreenDefinition;
170
+ readonly translate?: Translate;
171
+ }): ReactNode {
172
+ const { Banner, Text } = usePrimitives();
173
+ const Body = useDashboardBody();
174
+ if (Body === undefined) {
175
+ return (
176
+ <Banner padded variant="info" testId="kumiko-screen-dashboard-placeholder">
177
+ Dashboard screen <Text variant="code">{screen.id}</Text> — kein Dashboard-Body registriert
178
+ (createKumikoApp aus <Text variant="code">kumiko-renderer-web</Text> wired ihn automatisch).
179
+ </Banner>
180
+ );
181
+ }
182
+ return <Body screen={screen} {...(translate !== undefined && { translate })} />;
183
+ }
184
+
158
185
  // Lookup-Body für custom-screens: schaut die Component aus dem
159
186
  // CustomScreens-Context (gefüttert von clientFeatures.components in
160
187
  // createKumikoApp). Wenn weder Provider gemounted noch screenId
@@ -1036,6 +1063,7 @@ function ProjectionListBody({
1036
1063
  const { Banner } = usePrimitives();
1037
1064
  const t = useTranslation();
1038
1065
  const nav = useNav();
1066
+ const dispatcher = useOptionalDispatcher();
1039
1067
  const effectiveTranslate = translate ?? t;
1040
1068
  const entity = useMemo(() => synthesizeProjectionEntity(screen.columns), [screen.columns]);
1041
1069
  const listScreen = useMemo(() => synthesizeProjectionScreen(screen), [screen]);
@@ -1066,39 +1094,88 @@ function ProjectionListBody({
1066
1094
  if (screen.rowActions === undefined) return undefined;
1067
1095
  const out: DataTableRowAction[] = [];
1068
1096
  for (const action of screen.rowActions) {
1069
- // v1: nur navigate (writeHandler-RowActions bräuchten den Dispatcher-Pfad).
1070
- if (action.kind !== "navigate") continue;
1071
- const navigateAction = action;
1072
- const visible = action.visible;
1097
+ if (action.kind === "navigate") {
1098
+ const navigateAction = action;
1099
+ const visible = action.visible;
1100
+ out.push({
1101
+ id: action.id,
1102
+ label: effectiveTranslate(action.label),
1103
+ ...(action.style !== undefined && { style: action.style }),
1104
+ onTrigger: (row: ListRowViewModel) => runNavigate(navigateAction, row),
1105
+ ...(visible !== undefined && {
1106
+ isVisible: (row: ListRowViewModel) => evalFieldCondition(visible, row.values),
1107
+ }),
1108
+ });
1109
+ continue;
1110
+ }
1111
+ // writeHandler (default-kind) — gleicher Dispatch-Pfad wie entityList:
1112
+ // Failure-Result MUSS zum Error werden (sonst schließt der Confirm-
1113
+ // Dialog kommentarlos).
1114
+ if (dispatcher === undefined) continue;
1115
+ const writeAction = action as RowActionWriteHandler;
1116
+ const writeVisible = writeAction.visible;
1073
1117
  out.push({
1074
- id: action.id,
1075
- label: effectiveTranslate(action.label),
1076
- ...(action.style !== undefined && { style: action.style }),
1077
- onTrigger: (row: ListRowViewModel) => runNavigate(navigateAction, row),
1078
- ...(visible !== undefined && {
1079
- isVisible: (row: ListRowViewModel) => evalFieldCondition(visible, row.values),
1118
+ id: writeAction.id,
1119
+ label: effectiveTranslate(writeAction.label),
1120
+ ...(writeAction.style !== undefined && { style: writeAction.style }),
1121
+ ...(writeAction.confirm !== undefined && {
1122
+ confirm: effectiveTranslate(writeAction.confirm),
1123
+ }),
1124
+ ...(writeAction.confirmLabel !== undefined && {
1125
+ confirmLabel: effectiveTranslate(writeAction.confirmLabel),
1126
+ }),
1127
+ onTrigger: async (row: ListRowViewModel) => {
1128
+ const payload =
1129
+ writeAction.payload !== undefined
1130
+ ? evalRowExtractor(writeAction.payload, row.values)
1131
+ : { id: row.values["id"] };
1132
+ const result = await dispatcher.write(writeAction.handler, payload);
1133
+ if (!result.isSuccess) {
1134
+ throw new WriteFailedError(
1135
+ result.error,
1136
+ dispatcherErrorText(result.error, effectiveTranslate),
1137
+ );
1138
+ }
1139
+ },
1140
+ ...(writeVisible !== undefined && {
1141
+ isVisible: (row: ListRowViewModel) => evalFieldCondition(writeVisible, row.values),
1080
1142
  }),
1081
1143
  });
1082
1144
  }
1083
1145
  return out.length > 0 ? out : undefined;
1084
- }, [screen.rowActions, effectiveTranslate, runNavigate]);
1146
+ }, [screen.rowActions, effectiveTranslate, runNavigate, dispatcher]);
1085
1147
 
1086
1148
  const toolbarActions = useMemo((): readonly ToolbarActionButton[] | undefined => {
1087
1149
  if (screen.toolbarActions === undefined) return undefined;
1088
1150
  const out: ToolbarActionButton[] = [];
1089
1151
  for (const action of screen.toolbarActions) {
1090
- // v1: nur navigate (writeHandler-Toolbar bräuchte den Dispatcher-Pfad).
1091
- if (action.kind !== "navigate") continue;
1092
- const target = action.screen;
1152
+ if (action.kind === "navigate") {
1153
+ const target = action.screen;
1154
+ out.push({
1155
+ id: action.id,
1156
+ label: effectiveTranslate(action.label),
1157
+ ...(action.style !== undefined && { style: action.style }),
1158
+ onTrigger: () => nav.navigate({ screenId: target }),
1159
+ });
1160
+ continue;
1161
+ }
1162
+ // writeHandler — analog entityList; ohne Dispatcher skippen statt crashen.
1163
+ if (dispatcher === undefined) continue;
1093
1164
  out.push({
1094
1165
  id: action.id,
1095
1166
  label: effectiveTranslate(action.label),
1096
1167
  ...(action.style !== undefined && { style: action.style }),
1097
- onTrigger: () => nav.navigate({ screenId: target }),
1168
+ ...(action.confirm !== undefined && { confirm: effectiveTranslate(action.confirm) }),
1169
+ ...(action.confirmLabel !== undefined && {
1170
+ confirmLabel: effectiveTranslate(action.confirmLabel),
1171
+ }),
1172
+ onTrigger: async () => {
1173
+ await dispatcher.write(action.handler, action.payload ?? {});
1174
+ },
1098
1175
  });
1099
1176
  }
1100
1177
  return out.length > 0 ? out : undefined;
1101
- }, [screen.toolbarActions, effectiveTranslate, nav]);
1178
+ }, [screen.toolbarActions, effectiveTranslate, nav, dispatcher]);
1102
1179
 
1103
1180
  if (rowsQuery.loading && rowsQuery.data === null) {
1104
1181
  return (
@@ -40,6 +40,7 @@ const testPrimitives: CorePrimitives = {
40
40
  Lightbox: noop,
41
41
  ConfigSourceBadge: noop,
42
42
  ConfigCascadeView: noop,
43
+ Link: noop,
43
44
  };
44
45
 
45
46
  function moneyField(): EditFieldViewModel {
@@ -0,0 +1,25 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { act, renderHook } from "@testing-library/react";
3
+ import { useDisclosure } from "../use-disclosure";
4
+
5
+ describe("useDisclosure", () => {
6
+ test("open/close/toggle steuern den Zustand", () => {
7
+ const { result } = renderHook(() => useDisclosure());
8
+ expect(result.current.open).toBe(false);
9
+ act(() => result.current.onOpen());
10
+ expect(result.current.open).toBe(true);
11
+ act(() => result.current.onClose());
12
+ expect(result.current.open).toBe(false);
13
+ act(() => result.current.onToggle());
14
+ expect(result.current.open).toBe(true);
15
+ });
16
+
17
+ test("Callbacks sind referenz-stabil über Re-Renders", () => {
18
+ const { result, rerender } = renderHook(() => useDisclosure(true));
19
+ const first = result.current;
20
+ rerender();
21
+ expect(result.current.onOpen).toBe(first.onOpen);
22
+ expect(result.current.onClose).toBe(first.onClose);
23
+ expect(result.current.onToggle).toBe(first.onToggle);
24
+ });
25
+ });
@@ -0,0 +1,76 @@
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 { useMutation } from "../use-mutation";
7
+
8
+ function makeDispatcher(write: Dispatcher["write"]): Dispatcher {
9
+ return {
10
+ write,
11
+ query: (async () => ({ isSuccess: true, data: {} })) as unknown as Dispatcher["query"],
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("useMutation", () => {
29
+ test("Success setzt data, pending toggelt, Result wird durchgereicht", async () => {
30
+ let resolve: (() => void) | undefined;
31
+ const gate = new Promise<void>((r) => {
32
+ resolve = r;
33
+ });
34
+ const dispatcher = makeDispatcher((async (_type: string, payload: unknown) => {
35
+ await gate;
36
+ return { isSuccess: true, data: { echoed: payload } };
37
+ }) as unknown as Dispatcher["write"]);
38
+
39
+ const { result } = renderHook(() => useMutation<{ echoed: unknown }>("f:write:x:create"), {
40
+ wrapper: wrapperFor(dispatcher),
41
+ });
42
+
43
+ expect(result.current.pending).toBe(false);
44
+ let outcome: Awaited<ReturnType<typeof result.current.mutate>> | undefined;
45
+ act(() => {
46
+ void result.current.mutate({ name: "a" }).then((r) => {
47
+ outcome = r;
48
+ });
49
+ });
50
+ await waitFor(() => expect(result.current.pending).toBe(true));
51
+ act(() => resolve?.());
52
+ await waitFor(() => expect(result.current.pending).toBe(false));
53
+ expect(result.current.data).toEqual({ echoed: { name: "a" } });
54
+ expect(result.current.error).toBeNull();
55
+ expect(outcome?.isSuccess).toBe(true);
56
+ });
57
+
58
+ test("Failure setzt error, reset räumt auf", async () => {
59
+ const dispatcher = makeDispatcher((async () => ({
60
+ isSuccess: false,
61
+ error: { code: "conflict", message: "boom", i18nKey: "errors.conflict" },
62
+ })) as unknown as Dispatcher["write"]);
63
+
64
+ const { result } = renderHook(() => useMutation("f:write:x:create"), {
65
+ wrapper: wrapperFor(dispatcher),
66
+ });
67
+
68
+ await act(async () => {
69
+ await result.current.mutate({});
70
+ });
71
+ expect(result.current.error?.code).toBe("conflict");
72
+ act(() => result.current.reset());
73
+ expect(result.current.error).toBeNull();
74
+ expect(result.current.data).toBeNull();
75
+ });
76
+ });
@@ -0,0 +1,20 @@
1
+ import { useCallback, useState } from "react";
2
+
3
+ // Open/close state for dialogs, sheets and collapsibles — the standard
4
+ // replacement for the hand-rolled `useState(false)` + toggle-callback
5
+ // trio in app screens. All callbacks are referentially stable.
6
+
7
+ export type UseDisclosureResult = {
8
+ readonly open: boolean;
9
+ readonly onOpen: () => void;
10
+ readonly onClose: () => void;
11
+ readonly onToggle: () => void;
12
+ };
13
+
14
+ export function useDisclosure(initialOpen = false): UseDisclosureResult {
15
+ const [open, setOpen] = useState(initialOpen);
16
+ const onOpen = useCallback(() => setOpen(true), []);
17
+ const onClose = useCallback(() => setOpen(false), []);
18
+ const onToggle = useCallback(() => setOpen((prev) => !prev), []);
19
+ return { open, onOpen, onClose, onToggle };
20
+ }
@@ -0,0 +1,51 @@
1
+ import type { DispatcherError, WriteResult } from "@cosmicdrift/kumiko-headless";
2
+ import { useCallback, useState } from "react";
3
+ import { useDispatcher } from "../context/dispatcher-context";
4
+
5
+ // React wrapper around dispatcher.write — the write-side sibling of
6
+ // useQuery. One hook instance per handler-type; `mutate` carries the
7
+ // payload so a single instance serves list-row actions with varying
8
+ // payloads.
9
+ //
10
+ // `mutate` resolves with the raw WriteResult so callers can branch
11
+ // (navigate on success, keep the form open on failure) without waiting
12
+ // for a re-render of `error`/`data`.
13
+
14
+ export type UseMutationResult<TData> = {
15
+ readonly mutate: (payload: unknown) => Promise<WriteResult<TData>>;
16
+ readonly pending: boolean;
17
+ readonly error: DispatcherError | null;
18
+ readonly data: TData | null;
19
+ readonly reset: () => void;
20
+ };
21
+
22
+ export function useMutation<TData = unknown>(type: string): UseMutationResult<TData> {
23
+ const dispatcher = useDispatcher();
24
+ const [pending, setPending] = useState(false);
25
+ const [error, setError] = useState<DispatcherError | null>(null);
26
+ const [data, setData] = useState<TData | null>(null);
27
+
28
+ const mutate = useCallback(
29
+ async (payload: unknown): Promise<WriteResult<TData>> => {
30
+ setPending(true);
31
+ setError(null);
32
+ const result = await dispatcher.write<TData>(type, payload);
33
+ if (result.isSuccess) {
34
+ setData(result.data);
35
+ } else {
36
+ setError(result.error);
37
+ }
38
+ setPending(false);
39
+ return result;
40
+ },
41
+ [dispatcher, type],
42
+ );
43
+
44
+ const reset = useCallback(() => {
45
+ setPending(false);
46
+ setError(null);
47
+ setData(null);
48
+ }, []);
49
+
50
+ return { mutate, pending, error, data, reset };
51
+ }
@@ -45,9 +45,14 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
45
45
  "kumiko.combobox.loading": "Lade…",
46
46
  "kumiko.combobox.placeholder": "—",
47
47
 
48
+ // Widgets — Query-States (QueryTable, LoadingState, ErrorState).
49
+ "kumiko.widget.loading": "Lade…",
50
+ "kumiko.widget.error.title": "Konnte nicht geladen werden.",
51
+
48
52
  // Nav — Sidebar Tree (Toggle-aria-Labels).
49
53
  "kumiko.nav.expand": "Aufklappen",
50
54
  "kumiko.nav.collapse": "Zuklappen",
55
+ "kumiko.nav.search": "Navigation durchsuchen…",
51
56
 
52
57
  // Dialog — Confirm-Buttons + Close-aria-Label.
53
58
  "kumiko.dialog.confirm": "Bestätigen",
@@ -149,8 +154,12 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
149
154
  "kumiko.combobox.loading": "Loading…",
150
155
  "kumiko.combobox.placeholder": "—",
151
156
 
157
+ "kumiko.widget.loading": "Loading…",
158
+ "kumiko.widget.error.title": "Couldn't load.",
159
+
152
160
  "kumiko.nav.expand": "Expand",
153
161
  "kumiko.nav.collapse": "Collapse",
162
+ "kumiko.nav.search": "Search navigation…",
154
163
 
155
164
  "kumiko.dialog.confirm": "Confirm",
156
165
  "kumiko.dialog.cancel": "Cancel",
package/src/index.ts CHANGED
@@ -18,6 +18,8 @@ export type {
18
18
  export { ColumnRenderersProvider, useColumnRenderer } from "./app/column-renderers";
19
19
  export type { CustomScreensMap, CustomScreensProviderProps } from "./app/custom-screens";
20
20
  export { CustomScreensProvider, useCustomScreenComponent } from "./app/custom-screens";
21
+ export type { DashboardBodyProps, DashboardBodyProviderProps } from "./app/dashboard-body";
22
+ export { DashboardBodyProvider, useDashboardBody } from "./app/dashboard-body";
21
23
  export type {
22
24
  ExtensionFormRegistry,
23
25
  ExtensionFormSubmitHandler,
@@ -62,6 +64,8 @@ export {
62
64
  REFERENCE_LIST_LOOKUP_LIMIT,
63
65
  REFERENCE_SEARCH_DEBOUNCE_MS,
64
66
  } from "./hooks/reference-limits";
67
+ export type { UseDisclosureResult } from "./hooks/use-disclosure";
68
+ export { useDisclosure } from "./hooks/use-disclosure";
65
69
  export type { UseFormOptions, UseFormResult } from "./hooks/use-form";
66
70
  export { useForm } from "./hooks/use-form";
67
71
  export type {
@@ -71,6 +75,8 @@ export type {
71
75
  ListUrlStateApi,
72
76
  } from "./hooks/use-list-url-state";
73
77
  export { useListUrlState } from "./hooks/use-list-url-state";
78
+ export type { UseMutationResult } from "./hooks/use-mutation";
79
+ export { useMutation } from "./hooks/use-mutation";
74
80
  export type { UseQueryOptions, UseQueryResult } from "./hooks/use-query";
75
81
  export { useQuery } from "./hooks/use-query";
76
82
  export { useStore, useStoreSelector } from "./hooks/use-store";
@@ -111,6 +117,7 @@ export type {
111
117
  HeadingProps,
112
118
  InputProps,
113
119
  LightboxProps,
120
+ LinkProps,
114
121
  PrimitivesProviderProps,
115
122
  PrimitivesRegistry,
116
123
  RuntimeRenderer,
@@ -66,8 +66,25 @@ export type ButtonProps = {
66
66
  readonly loading?: boolean;
67
67
  /** Semantische Klasse — default="primary". Custom-Impls entscheiden
68
68
  * was daraus visuell wird; die Renderer verwenden "primary" für
69
- * Save, "danger" für Delete, "secondary" für Confirm-State. */
70
- readonly variant?: "primary" | "secondary" | "danger";
69
+ * Save, "danger" für Delete, "secondary" für Confirm-State,
70
+ * "link" für Inline-Aktionen im Fließtext (kein BG, underline). */
71
+ readonly variant?: "primary" | "secondary" | "danger" | "link";
72
+ readonly children: ReactNode;
73
+ readonly testId?: string;
74
+ };
75
+
76
+ /** Navigations-Link. `variant="button"` rendert die Button-Optik auf einem
77
+ * semantischen Anchor (z.B. „Zum Login" nach Reset-Success), `muted` den
78
+ * dezenten Sekundär-Link. Interne SPA-Navigation läuft über den Browser-
79
+ * Default (History-Integration liegt beim Nav-Layer, nicht am Primitive). */
80
+ export type LinkProps = {
81
+ readonly href: string;
82
+ readonly variant?: "default" | "button" | "muted";
83
+ /** `_blank` setzt in der Web-Impl automatisch rel="noreferrer". */
84
+ readonly target?: "_blank";
85
+ /** Layout-Zusätze (self-center, text-xs) — Web merged via cn(),
86
+ * Native-Impls ignorieren es (Präzedenz: CardProps.className). */
87
+ readonly className?: string;
71
88
  readonly children: ReactNode;
72
89
  readonly testId?: string;
73
90
  };
@@ -533,9 +550,10 @@ export type GridCellProps = {
533
550
  /** Semantischer Text. Variants bilden Standard-Typografie-Rollen ab —
534
551
  * `body` ist Default, `small` für sekundäre Labels, `code` für inline
535
552
  * monospace (entityId, screen-id), `required-mark` für das Sternchen
536
- * hinter Labels. Custom-Impls mappen auf ihren TypeScale. */
553
+ * hinter Labels, `muted` für gedimmten Fließtext (text-sm muted-
554
+ * foreground). Custom-Impls mappen auf ihren TypeScale. */
537
555
  export type TextProps = {
538
- readonly variant?: "body" | "small" | "code" | "required-mark";
556
+ readonly variant?: "body" | "small" | "code" | "required-mark" | "muted";
539
557
  readonly children: ReactNode;
540
558
  readonly testId?: string;
541
559
  };
@@ -674,6 +692,7 @@ export type CorePrimitives = {
674
692
  readonly Lightbox: ComponentType<LightboxProps>;
675
693
  readonly ConfigSourceBadge: ComponentType<ConfigSourceBadgeProps>;
676
694
  readonly ConfigCascadeView: ComponentType<ConfigCascadeViewProps>;
695
+ readonly Link: ComponentType<LinkProps>;
677
696
  };
678
697
 
679
698
  /** Offene Extension-Zone für App-eigene Primitives. Devs erweitern