@cosmicdrift/kumiko-renderer 0.382.0 → 0.383.1

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.
Files changed (44) hide show
  1. package/dist/app/action-form-shim.js +4 -3
  2. package/dist/app/entity-list-expanded-row.js +2 -1
  3. package/dist/app/error-reference.d.ts +11 -0
  4. package/dist/app/error-reference.js +18 -0
  5. package/dist/app/header-actions-bar.d.ts +2 -1
  6. package/dist/app/kumiko-screen.js +9 -16
  7. package/dist/app/projection-detail-shim.js +2 -3
  8. package/dist/app/query-state-banners.js +3 -2
  9. package/dist/app/return-to.js +14 -8
  10. package/dist/app/secrets-edit-body.js +0 -1
  11. package/dist/components/error-boundary.d.ts +9 -2
  12. package/dist/components/error-boundary.js +9 -7
  13. package/dist/components/error-id.d.ts +1 -0
  14. package/dist/components/error-id.js +16 -0
  15. package/dist/components/list-empty-state.js +2 -1
  16. package/dist/components/related-list-section.js +2 -1
  17. package/dist/components/render-edit-action-button.d.ts +2 -1
  18. package/dist/components/render-edit-action-button.js +2 -1
  19. package/dist/components/render-edit.js +2 -1
  20. package/dist/components/use-action-menu-items.d.ts +2 -1
  21. package/dist/components/use-action-menu-items.js +2 -1
  22. package/dist/components/write-form-section.js +6 -2
  23. package/dist/headless/view-model/edit.js +1 -1
  24. package/dist/headless/view-model/types.d.ts +1 -1
  25. package/dist/i18n-defaults.js +4 -0
  26. package/dist/index.d.ts +1 -0
  27. package/dist/index.js +1 -0
  28. package/dist/leave-guard.js +14 -3
  29. package/dist/web/app/client-error-reporter.d.ts +15 -0
  30. package/dist/web/app/client-error-reporter.js +83 -0
  31. package/dist/web/app/create-app.d.ts +9 -1
  32. package/dist/web/app/create-app.js +16 -2
  33. package/dist/web/app/dashboard-body.js +2 -1
  34. package/dist/web/app/root-error-fallback.d.ts +4 -2
  35. package/dist/web/app/root-error-fallback.js +79 -6
  36. package/dist/web/app/screen-error-fallback.d.ts +2 -1
  37. package/dist/web/app/screen-error-fallback.js +6 -5
  38. package/dist/web/layout/editor-panel.js +2 -2
  39. package/dist/web/layout/target-resolver-stub.js +5 -3
  40. package/dist/web/primitives/index.js +4 -1
  41. package/dist/web/primitives/toast.d.ts +1 -0
  42. package/dist/web/primitives/toast.js +7 -1
  43. package/package.json +5 -5
  44. package/src/changes.json +31 -0
@@ -37,9 +37,10 @@ function lockExcludedFieldsInSection(section, excluded) {
37
37
  return section;
38
38
  return {
39
39
  ...section,
40
- // kumiko-lint-ignore section-fields-raw rewrites both sources in place — the groups branch below does the same for groups[].fields
41
- fields: section.fields.map((spec) => lockExcludedFieldSpec(spec, excluded)),
42
- ...(section.groups !== undefined && {
40
+ ...("fields" in section && {
41
+ fields: section.fields.map((spec) => lockExcludedFieldSpec(spec, excluded)),
42
+ }),
43
+ ...("groups" in section && {
43
44
  groups: section.groups.map((group) => ({
44
45
  ...group,
45
46
  fields: group.fields.map((spec) => lockExcludedFieldSpec(spec, excluded)),
@@ -6,6 +6,7 @@ import { useOptionalDispatcher } from "../context/dispatcher-context.js";
6
6
  import { useTranslation } from "../i18n.js";
7
7
  import { usePrimitives } from "../primitives.js";
8
8
  import { useClientActionRuntime } from "./client-actions.js";
9
+ import { ErrorReference } from "./error-reference.js";
9
10
  import { useNav } from "./nav.js";
10
11
  import { useReturnHost } from "./return-to.js";
11
12
  import { buildRecordActions } from "./row-actions.js";
@@ -49,5 +50,5 @@ export function EntityListExpandedRow({ section, row, featureName, translate, ex
49
50
  clientActions,
50
51
  ]);
51
52
  const titleActionButtons = titleActions?.map((action) => (_jsx(RenderEditActionButton, { action: action, Button: Button, Dialog: Dialog, onError: setActionError }, action.id)));
52
- return (_jsxs(_Fragment, { children: [actionError !== null && (_jsx(Banner, { variant: "error", testId: `row-${row.id}-expansion-action-error`, children: actionError })), _jsx(RelatedListSection, { embedded: true, section: section, parentId: String(row.values["id"] ?? row.id), record: row.values, featureName: featureName, translate: effectiveTranslate, onOpenDrawer: openDrawer, onAfterWrite: onAfterWrite, ...(titleActionButtons !== undefined && { actions: titleActionButtons }) }, expansionNonce)] }));
53
+ return (_jsxs(_Fragment, { children: [actionError !== null && (_jsxs(Banner, { variant: "error", testId: `row-${row.id}-expansion-action-error`, children: [actionError.message, _jsx(ErrorReference, { requestId: actionError.requestId })] })), _jsx(RelatedListSection, { embedded: true, section: section, parentId: String(row.values["id"] ?? row.id), record: row.values, featureName: featureName, translate: effectiveTranslate, onOpenDrawer: openDrawer, onAfterWrite: onAfterWrite, ...(titleActionButtons !== undefined && { actions: titleActionButtons }) }, expansionNonce)] }));
53
54
  }
@@ -0,0 +1,11 @@
1
+ import type { ReactNode } from "react";
2
+ /** An error message plus the server request id support needs to find the log line. */
3
+ export type ErrorNotice = {
4
+ readonly message: string;
5
+ readonly requestId?: string;
6
+ };
7
+ export declare function errorNoticeOf(error: unknown): ErrorNotice;
8
+ export declare function ErrorReference({ requestId, testId, }: {
9
+ readonly requestId: string | undefined;
10
+ readonly testId?: string;
11
+ }): ReactNode;
@@ -0,0 +1,18 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useTranslation } from "../i18n.js";
3
+ import { usePrimitives } from "../primitives.js";
4
+ import { WriteFailedError } from "./write-failed-error.js";
5
+ export function errorNoticeOf(error) {
6
+ if (error instanceof WriteFailedError) {
7
+ const { requestId } = error.dispatcherError;
8
+ return { message: error.message, ...(requestId !== undefined && { requestId }) };
9
+ }
10
+ return { message: error instanceof Error ? error.message : String(error) };
11
+ }
12
+ export function ErrorReference({ requestId, testId = "error-reference", }) {
13
+ const { Grid, Text, CopyButton } = usePrimitives();
14
+ const t = useTranslation();
15
+ if (requestId === undefined || requestId === "")
16
+ return null;
17
+ return (_jsxs(Grid, { columns: "auto", children: [_jsx(Text, { variant: "small", testId: `${testId}-id`, children: t("kumiko.error.reference", { id: requestId }) }), CopyButton !== undefined && (_jsx(CopyButton, { text: requestId, label: t("kumiko.error.copyReference"), copiedLabel: t("kumiko.error.referenceCopied"), className: "min-h-0 h-7", testId: `${testId}-copy` }))] }));
18
+ }
@@ -1,6 +1,7 @@
1
1
  import type { ReactNode } from "react";
2
2
  import type { RenderEditAction } from "../components/render-edit-types.ts";
3
3
  import type { usePrimitives } from "../primitives.tsx";
4
+ import type { ErrorNotice } from "./error-reference.tsx";
4
5
  export declare function HeaderActionsBar({ actions, Button, Dialog, ActionOverflowMenu, onError, collapseAfterPrimary, compact, preferredPrimaryId, PageHeader, overflowTestId, }: {
5
6
  /** `primaryAction` of the screen: wins over the edit/primary-style precedence. */
6
7
  readonly preferredPrimaryId?: string;
@@ -13,7 +14,7 @@ export declare function HeaderActionsBar({ actions, Button, Dialog, ActionOverfl
13
14
  readonly Button: ReturnType<typeof usePrimitives>["Button"];
14
15
  readonly Dialog: ReturnType<typeof usePrimitives>["Dialog"];
15
16
  readonly ActionOverflowMenu: ReturnType<typeof usePrimitives>["ActionOverflowMenu"];
16
- readonly onError: (text: string | null) => void;
17
+ readonly onError: (notice: ErrorNotice | null) => void;
17
18
  /** Test id of the overflow menu; projectionDetail's by default. */
18
19
  readonly overflowTestId?: string;
19
20
  }): ReactNode;
@@ -1,5 +1,5 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { evalFieldCondition, isRecordLocked, isWriteFormEditSection, metricField, parseRefTarget, } from "@cosmicdrift/kumiko-framework/ui-types";
2
+ import { evalFieldCondition, isRecordLocked, isWriteFormEditSection, parseRefTarget, } from "@cosmicdrift/kumiko-framework/ui-types";
3
3
  import { resolveActionIcon } from "@cosmicdrift/kumiko-types/action-icon";
4
4
  import { TENANT_CURRENCY_CONFIG_KEY } from "@cosmicdrift/kumiko-types/fields";
5
5
  import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
@@ -34,6 +34,7 @@ import { useCustomScreenComponent } from "./custom-screens.js";
34
34
  import { useDashboardBody } from "./dashboard-body.js";
35
35
  import { applyEditPrefill } from "./edit-prefill.js";
36
36
  import { EntityListExpandedRow } from "./entity-list-expanded-row.js";
37
+ import { ErrorReference } from "./error-reference.js";
37
38
  import { FacetCountBridges } from "./facet-count-bridge.js";
38
39
  import { buildFormSchema } from "./form-schema.js";
39
40
  import { HeaderActionsBar } from "./header-actions-bar.js";
@@ -1917,14 +1918,6 @@ function ProjectionListBody({ schema, screen, translate, onRowClick, }) {
1917
1918
  void rowsQuery.refetch();
1918
1919
  } })] }));
1919
1920
  }
1920
- function metricLabelKey(metric, fieldLabels) {
1921
- if (typeof metric !== "string" && metric.label !== undefined)
1922
- return metric.label;
1923
- return fieldLabels?.[metricField(metric)];
1924
- }
1925
- function metricNavigateSpec(metric) {
1926
- return typeof metric === "string" ? undefined : metric.navigate;
1927
- }
1928
1921
  // Reuses runProjectionRowNavigate (row-actions.ts) instead of re-deriving
1929
1922
  // navigate execution — a metric click has the same entity/screen/params
1930
1923
  // shape as a RowActionNavigate, minus the fields (id, label, style) that
@@ -1934,8 +1927,9 @@ function runMetricNavigate(nav, navigate, record, host) {
1934
1927
  // activates that tab — no route change, so runProjectionRowNavigate
1935
1928
  // (which always navigates) doesn't apply here.
1936
1929
  if (navigate.screen === undefined && navigate.entity === undefined) {
1937
- if (navigate.tab !== undefined)
1938
- nav.setSearchParams({ tab: navigate.tab });
1930
+ const { tab } = navigate;
1931
+ if (tab !== undefined)
1932
+ requestLeave(() => nav.setSearchParams({ tab }));
1939
1933
  return;
1940
1934
  }
1941
1935
  const base = { kind: "navigate", id: "metric-navigate", label: "" };
@@ -2303,12 +2297,11 @@ function ProjectionDetailBody({ schema, screen, translate, entityId, }) {
2303
2297
  // slots.header shares the header card with the actions (one card, actions
2304
2298
  // top right) instead of rendering as an unframed block above it.
2305
2299
  const metricItems = screen.metrics?.map((metric) => {
2306
- const field = metricField(metric);
2307
- const labelKey = metricLabelKey(metric, screen.fieldLabels);
2308
- const label = labelKey !== undefined ? effectiveTranslate(labelKey) : field;
2300
+ const field = metric.field;
2301
+ const label = effectiveTranslate(metric.label);
2309
2302
  const value = String(record[field] ?? "");
2310
2303
  const testId = `kumiko-screen-projection-detail-metric-${field}`;
2311
- const navigate = metricNavigateSpec(metric);
2304
+ const navigate = metric.navigate;
2312
2305
  const onPress = navigate !== undefined && navigateTargetAllows(navigate, appFeatures, userRoles)
2313
2306
  ? () => runMetricNavigate(nav, navigate, record, host)
2314
2307
  : undefined;
@@ -2323,7 +2316,7 @@ function ProjectionDetailBody({ schema, screen, translate, entityId, }) {
2323
2316
  const statusBadge = header?.status !== undefined && StatusBadge !== undefined ? (_jsx(StatusBadge, { value: String(record[header.status] ?? ""), tone: headerStatusTone(header, record), testId: "kumiko-screen-projection-detail-status" })) : undefined;
2324
2317
  // Lives in the band's subtitle row so it shares the metrics' horizontal inset.
2325
2318
  const bandSubtitle = pageHeaderCompact && statusBadge !== undefined ? (_jsxs(_Fragment, { children: [_jsx(Grid, { columns: "auto", testId: "kumiko-screen-projection-detail-status-row", children: statusBadge }), headerSubtitleSlot] })) : (headerSubtitleSlot);
2326
- const actionErrorBanner = actionError !== null && (_jsx(Banner, { variant: "error", testId: "render-edit-action-error", children: actionError }));
2319
+ const actionErrorBanner = actionError !== null && (_jsxs(Banner, { variant: "error", testId: "render-edit-action-error", children: [actionError.message, _jsx(ErrorReference, { requestId: actionError.requestId })] }));
2327
2320
  const renderHeaderContent = (headerSlot) => (_jsxs(_Fragment, { children: [usesPageHeaderSlot ? (_jsxs(_Fragment, { children: [_jsx(PageHeader, { ...(header !== undefined && { title: String(record[header.title] ?? "") }), ...(header === undefined && recordTitle !== undefined && { title: recordTitle }), ...(!pageHeaderCompact && statusBadge !== undefined && { status: statusBadge }), ...(hasHeaderActions && {
2328
2321
  actions: (_jsx(Grid, { columns: "auto", testId: "kumiko-screen-projection-detail-actions", children: _jsx(HeaderActionsBar, { actions: headerActionsList, Button: Button, Dialog: Dialog, ActionOverflowMenu: ActionOverflowMenu, onError: setActionError, collapseAfterPrimary: true, compact: pageHeaderCompact, PageHeader: PageHeader, ...(screen.primaryAction !== undefined && {
2329
2322
  preferredPrimaryId: screen.primaryAction,
@@ -45,9 +45,8 @@ export function synthesizeProjectionDetailScreen(screen) {
45
45
  return section;
46
46
  return {
47
47
  ...section,
48
- // kumiko-lint-ignore section-fields-raw rewrites both sources in place — the groups branch below does the same for groups[].fields
49
- fields: section.fields.map(forceReadOnly),
50
- ...(section.groups !== undefined && {
48
+ ...("fields" in section && { fields: section.fields.map(forceReadOnly) }),
49
+ ...("groups" in section && {
51
50
  groups: section.groups.map((group) => ({
52
51
  ...group,
53
52
  fields: group.fields.map(forceReadOnly),
@@ -1,6 +1,7 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useTranslation } from "../i18n.js";
3
3
  import { usePrimitives } from "../primitives.js";
4
+ import { ErrorReference } from "./error-reference.js";
4
5
  import { dispatcherErrorText } from "./write-failed-error.js";
5
6
  export function QueryLoadingBanner() {
6
7
  const { Banner } = usePrimitives();
@@ -10,5 +11,5 @@ export function QueryLoadingBanner() {
10
11
  export function QueryErrorBanner({ error, translate, onRetry, }) {
11
12
  const { Banner, Button } = usePrimitives();
12
13
  const t = useTranslation();
13
- return (_jsx(Banner, { padded: true, variant: "error", testId: "kumiko-screen-error", actions: _jsx(Button, { variant: "secondary", size: "sm", onClick: onRetry, testId: "kumiko-screen-retry", children: t("kumiko.actions.retry") }), children: dispatcherErrorText(error, translate) }));
14
+ return (_jsxs(Banner, { padded: true, variant: "error", testId: "kumiko-screen-error", actions: _jsx(Button, { variant: "secondary", size: "sm", onClick: onRetry, testId: "kumiko-screen-retry", children: t("kumiko.actions.retry") }), children: [dispatcherErrorText(error, translate), _jsx(ErrorReference, { requestId: error.requestId })] }));
14
15
  }
@@ -1,6 +1,7 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { createContext, useContext, useMemo } from "react";
3
3
  import { useUserRoles } from "../context/user-roles-context.js";
4
+ import { requestLeave } from "../leave-guard.js";
4
5
  import { useAppFeatures } from "./app-features-context.js";
5
6
  import { useNav, } from "./nav.js";
6
7
  import { lastSegment } from "./qn.js";
@@ -91,14 +92,17 @@ function returnParamsFor(nav, target, host) {
91
92
  export function navigateWithReturnTo(nav, target, host, params) {
92
93
  // Snapshot before navigating — nav.searchParams still describes the host.
93
94
  const returnParams = returnParamsFor(nav, target, host);
94
- nav.navigate(target);
95
95
  const merged = {
96
96
  ...(params ?? {}),
97
97
  ...returnParams,
98
98
  };
99
- if (Object.keys(merged).length > 0) {
100
- nav.setSearchParams(merged);
101
- }
99
+ // One question for navigate + params: asked separately, a declined leave would still merge the params into the host URL.
100
+ requestLeave(() => {
101
+ nav.navigate(target);
102
+ if (Object.keys(merged).length > 0) {
103
+ nav.setSearchParams(merged);
104
+ }
105
+ });
102
106
  }
103
107
  /** Jumps back to an already-validated returnTo target and restores the host
104
108
  * search params carried in the same value. The host's own returnTo travels
@@ -109,10 +113,12 @@ export function navigateToReturn(nav, target) {
109
113
  // target elsewhere must not inherit it.
110
114
  const returnTo = raw === undefined ? undefined : splitReturnTo(raw);
111
115
  const state = returnTo?.path === formatReturnTo(target) ? returnTo.state : NO_RETURN_STATE;
112
- nav.navigate(target);
113
- // The explicit null drops this screen's own returnTo on nav impls whose
114
- // navigate keeps the query.
115
- nav.setSearchParams({ ...state, [RETURN_TO_PARAM]: state[RETURN_TO_PARAM] ?? null });
116
+ requestLeave(() => {
117
+ nav.navigate(target);
118
+ // The explicit null drops this screen's own returnTo on nav impls whose
119
+ // navigate keeps the query.
120
+ nav.setSearchParams({ ...state, [RETURN_TO_PARAM]: state[RETURN_TO_PARAM] ?? null });
121
+ });
116
122
  }
117
123
  export function navigateToReturnOr(nav, target, fallback) {
118
124
  if (target === undefined) {
@@ -12,7 +12,6 @@ function groupSectionsByTitle(sections) {
12
12
  const byTitle = new Map();
13
13
  for (const section of sections) {
14
14
  const title = section.title ?? "config.secrets.section";
15
- // kumiko-lint-ignore section-fields-raw secretsEdit section fields are field-id strings, not EditFieldSpec
16
15
  byTitle.set(title, [...(byTitle.get(title) ?? []), ...section.fields]);
17
16
  }
18
17
  return [...byTitle].map(([title, fields]) => ({ title, fields }));
@@ -1,6 +1,8 @@
1
1
  import { Component, type ErrorInfo, type ReactNode } from "react";
2
2
  export type ErrorBoundaryFallbackProps = {
3
3
  readonly error: Error;
4
+ /** Shown to the user so a support request can be matched to the report. */
5
+ readonly errorId: string;
4
6
  readonly reset: () => void;
5
7
  };
6
8
  export type ErrorBoundaryProps = {
@@ -9,10 +11,15 @@ export type ErrorBoundaryProps = {
9
11
  /** Prefix for the logged message, e.g. "screen" or "root". */
10
12
  readonly scope: string;
11
13
  /** Called after the default console.error log, e.g. to forward to telemetry. */
12
- readonly onError?: (error: Error, info: ErrorInfo) => void;
14
+ readonly onError?: (error: Error, info: ErrorInfo, context: {
15
+ readonly errorId: string;
16
+ }) => void;
13
17
  };
14
18
  type ErrorBoundaryState = {
15
- readonly error: Error | undefined;
19
+ readonly caught: {
20
+ readonly error: Error;
21
+ readonly errorId: string;
22
+ } | undefined;
16
23
  };
17
24
  export declare class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
18
25
  state: ErrorBoundaryState;
@@ -1,18 +1,20 @@
1
1
  import { ERROR_BOUNDARY_LOG_MARKER } from "@cosmicdrift/kumiko-types/error-boundary";
2
2
  import { Component } from "react";
3
+ import { createErrorId } from "./error-id.js";
3
4
  function toError(thrown) {
4
5
  return thrown instanceof Error ? thrown : new Error(String(thrown));
5
6
  }
6
7
  export class ErrorBoundary extends Component {
7
- state = { error: undefined };
8
+ state = { caught: undefined };
8
9
  static getDerivedStateFromError(thrown) {
9
- return { error: toError(thrown) };
10
+ return { caught: { error: toError(thrown), errorId: createErrorId() } };
10
11
  }
11
12
  componentDidCatch(thrown, info) {
12
13
  const error = toError(thrown);
14
+ const errorId = this.state.caught?.errorId ?? createErrorId();
13
15
  this.logCaught(error, info.componentStack);
14
16
  try {
15
- this.props.onError?.(error, info);
17
+ this.props.onError?.(error, info, { errorId });
16
18
  }
17
19
  catch (handlerError) {
18
20
  // A throw escaping componentDidCatch hands the error to the next boundary up, so a broken telemetry hook would replace the screen card with the root fallback.
@@ -24,12 +26,12 @@ export class ErrorBoundary extends Component {
24
26
  console.error(`[kumiko] ${this.props.scope} ${ERROR_BOUNDARY_LOG_MARKER}:`, error, detail);
25
27
  }
26
28
  reset = () => {
27
- this.setState({ error: undefined });
29
+ this.setState({ caught: undefined });
28
30
  };
29
31
  render() {
30
- const { error } = this.state;
31
- if (error === undefined)
32
+ const { caught } = this.state;
33
+ if (caught === undefined)
32
34
  return this.props.children;
33
- return this.props.fallback({ error, reset: this.reset });
35
+ return this.props.fallback({ ...caught, reset: this.reset });
34
36
  }
35
37
  }
@@ -0,0 +1 @@
1
+ export declare function createErrorId(): string;
@@ -0,0 +1,16 @@
1
+ import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
2
+ // The server's client-error endpoint requires a uuid, so the Math.random fallback
3
+ // (runtimes without WebCrypto) keeps the uuid v4 shape.
4
+ export function createErrorId() {
5
+ const webCrypto = propertyOf(globalThis, "crypto");
6
+ const randomUUID = propertyOf(webCrypto, "randomUUID");
7
+ if (typeof randomUUID === "function") {
8
+ const id = Reflect.apply(randomUUID, webCrypto, []);
9
+ if (typeof id === "string")
10
+ return id;
11
+ }
12
+ return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (placeholder) => {
13
+ const nibble = Math.floor(Math.random() * 16);
14
+ return (placeholder === "x" ? nibble : (nibble % 4) + 8).toString(16);
15
+ });
16
+ }
@@ -1,5 +1,6 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useState } from "react";
3
+ import { ErrorReference } from "../app/error-reference.js";
3
4
  import { usePrimitives } from "../primitives.js";
4
5
  import { RenderEditActionButton } from "./render-edit-action-button.js";
5
6
  export function ListEmptyStateBody({ content, narrowed, defaultFilterActive = false, onShowAll, createButton, translate, }) {
@@ -11,5 +12,5 @@ export function ListEmptyStateBody({ content, narrowed, defaultFilterActive = fa
11
12
  return (_jsxs(_Fragment, { children: [_jsx(Text, { children: translate("kumiko.list.empty.defaultFiltered") }), onShowAll !== undefined && (_jsx(Button, { variant: "secondary", onClick: onShowAll, testId: "render-list-empty-show-all", children: translate("kumiko.list.empty.showAll") }))] }));
12
13
  }
13
14
  const description = content === undefined ? translate("kumiko.list.empty.hint") : content.description;
14
- return (_jsxs(_Fragment, { children: [_jsx(Text, { children: content?.title ?? translate("kumiko.list.empty.title") }), description !== undefined && _jsx(Text, { variant: "small", children: description }), content?.action !== undefined && (_jsx(RenderEditActionButton, { action: content.action, Button: Button, Dialog: Dialog, onError: setActionError })), actionError !== null && (_jsx(Banner, { variant: "error", testId: "render-list-empty-state-action-error", children: actionError })), createButton] }));
15
+ return (_jsxs(_Fragment, { children: [_jsx(Text, { children: content?.title ?? translate("kumiko.list.empty.title") }), description !== undefined && _jsx(Text, { variant: "small", children: description }), content?.action !== undefined && (_jsx(RenderEditActionButton, { action: content.action, Button: Button, Dialog: Dialog, onError: setActionError })), actionError !== null && (_jsxs(Banner, { variant: "error", testId: "render-list-empty-state-action-error", children: [actionError.message, _jsx(ErrorReference, { requestId: actionError.requestId })] })), createButton] }));
15
16
  }
@@ -3,6 +3,7 @@ import { evalFieldCondition, normalizeListColumn, parseRefTarget, relatedListGro
3
3
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
4
  import { useAppFeatures } from "../app/app-features-context.js";
5
5
  import { useClientActionRuntime } from "../app/client-actions.js";
6
+ import { ErrorReference } from "../app/error-reference.js";
6
7
  import { buildFilterFacets, buildFilterPayload, mergeReferenceFacetOptions, resolveProjectionFacetSpecs, } from "../app/list-facets.js";
7
8
  import { useNav } from "../app/nav.js";
8
9
  import { ReferenceFacetBridges, } from "../app/reference-facet-bridge.js";
@@ -324,7 +325,7 @@ export function RelatedListSection({ section, parentId, record, featureName, tra
324
325
  (rowsQuery.data.total === undefined || rowsQuery.data.total > rowsQuery.data.rows.length);
325
326
  const content = rowsQuery.loading && rowsQuery.data === null ? (_jsx(Banner, { padded: true, variant: "loading", testId: "related-list-loading", children: t("kumiko.widget.loading") })) : rowsQuery.error ? (_jsx(Banner, { padded: true, variant: "error", testId: "related-list-error", children: dispatcherErrorText(rowsQuery.error, effectiveTranslate) })) : (_jsxs(_Fragment, { children: [truncated && (_jsx(Banner, { variant: "info", testId: "related-list-truncated", children: effectiveTranslate("kumiko.list.related-list-truncated", {
326
327
  count: sortedRows.length,
327
- }) })), emptyStateActionError !== null && (_jsx(Banner, { variant: "error", testId: "related-list-empty-state-action-error", children: emptyStateActionError })), _jsx(PageHeaderSlotAvailableProvider, { value: false, children: _jsx(RenderList, { screen: listScreen, entity: entity, rows: sortedRows, featureName: listFeatureName ?? featureName, translate: effectiveTranslate, ...(section.description !== undefined && { description: section.description }), ...(section.itemNoun !== undefined && { itemNounKey: section.itemNoun }), sort: sort, onSortChange: setSort, ...(section.searchable === true && {
328
+ }) })), emptyStateActionError !== null && (_jsxs(Banner, { variant: "error", testId: "related-list-empty-state-action-error", children: [emptyStateActionError.message, _jsx(ErrorReference, { requestId: emptyStateActionError.requestId })] })), _jsx(PageHeaderSlotAvailableProvider, { value: false, children: _jsx(RenderList, { screen: listScreen, entity: entity, rows: sortedRows, featureName: listFeatureName ?? featureName, translate: effectiveTranslate, ...(section.description !== undefined && { description: section.description }), ...(section.itemNoun !== undefined && { itemNounKey: section.itemNoun }), sort: sort, onSortChange: setSort, ...(section.searchable === true && {
328
329
  searchable: true,
329
330
  searchValue: search,
330
331
  onSearchChange: setSearch,
@@ -1,4 +1,5 @@
1
1
  import type { ReactNode } from "react";
2
+ import { type ErrorNotice } from "../app/error-reference.tsx";
2
3
  import type { usePrimitives } from "../primitives.tsx";
3
4
  import type { RenderEditAction } from "./render-edit-types.ts";
4
5
  export declare function needsActionConfirm(action: Pick<RenderEditAction, "confirm" | "confirmRequired" | "style">): boolean;
@@ -20,5 +21,5 @@ export declare function RenderEditActionButton({ action, iconOnly, hideIcon, com
20
21
  readonly compactLabel?: boolean;
21
22
  readonly Button: ReturnType<typeof usePrimitives>["Button"];
22
23
  readonly Dialog: ReturnType<typeof usePrimitives>["Dialog"];
23
- readonly onError: (text: string | null) => void;
24
+ readonly onError: (notice: ErrorNotice | null) => void;
24
25
  }): ReactNode;
@@ -1,5 +1,6 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useState } from "react";
3
+ import { errorNoticeOf } from "../app/error-reference.js";
3
4
  // Same rule as RowActionWriteHandler: "danger" forces a confirm even
4
5
  // without an explicit confirm key — unless `confirmRequired` overrides it
5
6
  // (schema-driven navigate/drawer actions, where the target form is itself
@@ -24,7 +25,7 @@ export function RenderEditActionButton({ action, iconOnly = false, hideIcon = fa
24
25
  await action.onPress();
25
26
  }
26
27
  catch (e) {
27
- onError(e instanceof Error ? e.message : String(e));
28
+ onError(errorNoticeOf(e));
28
29
  }
29
30
  finally {
30
31
  setBusy(false);
@@ -1,6 +1,7 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { evalFieldCondition, isFieldsEditSection, normalizeEditField, PROJECTION_DETAIL_ENTITY, sectionFieldSpecs, } from "@cosmicdrift/kumiko-framework/ui-types";
3
3
  import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
4
+ import { ErrorReference } from "../app/error-reference.js";
4
5
  import { computeEditViewModel, groupIssuesByPath, relevantFieldIssues, zodErrorToFieldIssues, } from "../headless/index.js";
5
6
  import { changedDerivedValues } from "./form-derivation-patch.js";
6
7
  import { RenderEditActionButton } from "./render-edit-action-button.js";
@@ -1519,7 +1520,7 @@ export function RenderEdit(props) {
1519
1520
  setFormError(null);
1520
1521
  setExtraDeleteIssues([]);
1521
1522
  }, testId: "render-edit-form-error-reload", children: translate("kumiko.actions.reload") })) : undefined, children: [_jsx(Text, { testId: "render-edit-form-error-key", children: translate(formError.i18nKey, formError.i18nParams) }), rootIssues.map((issue) => (_jsx(Text, { testId: "render-edit-form-error-root", children: translate(issue.i18nKey, issue.params) }, `${issue.path}:${issue.i18nKey}`))), extraDeleteIssues.map((issue) => (_jsx(Text, { testId: "render-edit-form-error-extra", children: translate(issue.i18nKey, issue.params) }, `${issue.path}:${issue.i18nKey}`)))] })), formError === null &&
1522
- rootIssues.map((issue) => (_jsx(Banner, { variant: "error", testId: "render-edit-root-issue", children: _jsx(Text, { children: translate(issue.i18nKey, issue.params) }) }, issue.i18nKey))), extensionErrorKey !== null && (_jsx(Banner, { variant: "error", testId: "render-edit-extension-error", children: _jsx(Text, { testId: "render-edit-extension-error-key", children: translate(extensionErrorKey) }) })), actionError !== null && (_jsx(Banner, { variant: "error", testId: "render-edit-action-error", children: actionError })), onDelete !== undefined && (_jsx(Dialog, { open: confirmDeleteOpen, onOpenChange: setConfirmDeleteOpen, title: translate("kumiko.actions.delete-confirm"), confirmLabel: translate("kumiko.actions.delete"), variant: "danger", onConfirm: async () => {
1523
+ rootIssues.map((issue) => (_jsx(Banner, { variant: "error", testId: "render-edit-root-issue", children: _jsx(Text, { children: translate(issue.i18nKey, issue.params) }) }, issue.i18nKey))), extensionErrorKey !== null && (_jsx(Banner, { variant: "error", testId: "render-edit-extension-error", children: _jsx(Text, { testId: "render-edit-extension-error-key", children: translate(extensionErrorKey) }) })), actionError !== null && (_jsxs(Banner, { variant: "error", testId: "render-edit-action-error", children: [actionError.message, _jsx(ErrorReference, { requestId: actionError.requestId })] })), onDelete !== undefined && (_jsx(Dialog, { open: confirmDeleteOpen, onOpenChange: setConfirmDeleteOpen, title: translate("kumiko.actions.delete-confirm"), confirmLabel: translate("kumiko.actions.delete"), variant: "danger", onConfirm: async () => {
1523
1524
  const rejection = await onDelete();
1524
1525
  // A rejected delete (e.g. a preDelete hook) carries its reason in
1525
1526
  // the field issues; the top-level key is only "validation failed".
@@ -1,9 +1,10 @@
1
1
  import { type ReactNode } from "react";
2
+ import { type ErrorNotice } from "../app/error-reference.tsx";
2
3
  import type { ActionMenuItemSpec, usePrimitives } from "../primitives.tsx";
3
4
  import type { RenderEditAction } from "./render-edit-types.ts";
4
5
  export declare function useActionMenuItems({ Dialog, onError, }: {
5
6
  readonly Dialog: ReturnType<typeof usePrimitives>["Dialog"];
6
- readonly onError: (text: string | null) => void;
7
+ readonly onError: (notice: ErrorNotice | null) => void;
7
8
  }): {
8
9
  readonly toMenuItem: (action: RenderEditAction) => ActionMenuItemSpec;
9
10
  readonly confirmDialog: ReactNode;
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { useState } from "react";
3
+ import { errorNoticeOf } from "../app/error-reference.js";
3
4
  import { needsActionConfirm, RenderEditActionConfirmDialog } from "./render-edit-action-button.js";
4
5
  // A menu item has no button of its own to carry RenderEditActionButton's busy
5
6
  // and confirm state, so this hook owns both for every item it creates.
@@ -14,7 +15,7 @@ export function useActionMenuItems({ Dialog, onError, }) {
14
15
  await action.onPress();
15
16
  }
16
17
  catch (e) {
17
- onError(e instanceof Error ? e.message : String(e));
18
+ onError(errorNoticeOf(e));
18
19
  }
19
20
  finally {
20
21
  setBusyActionId(null);
@@ -1,6 +1,7 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useEffect, useMemo, useRef, useState } from "react";
3
3
  import * as z from "zod";
4
+ import { ErrorReference } from "../app/error-reference.js";
4
5
  import { isPresent, REQUIRED_FIELD_I18N_KEY } from "../app/form-schema.js";
5
6
  import { dispatcherErrorText } from "../app/write-failed-error.js";
6
7
  import { I18N_KEY_PARAM } from "../headless/index.js";
@@ -72,7 +73,10 @@ export function WriteFormSection({ section, featureName, translate, hideTitle, o
72
73
  // refine, a hidden field).
73
74
  const issuePaths = (result.error.details?.fields ?? []).map((i) => i.path);
74
75
  setError(hasIssueWithoutVisibleField(issuePaths, section.fields)
75
- ? dispatcherErrorText(result.error, effectiveTranslate)
76
+ ? {
77
+ message: dispatcherErrorText(result.error, effectiveTranslate),
78
+ ...(result.error.requestId !== undefined && { requestId: result.error.requestId }),
79
+ }
76
80
  : null);
77
81
  }
78
82
  finally {
@@ -103,7 +107,7 @@ export function WriteFormSection({ section, featureName, translate, hideTitle, o
103
107
  controller.setErrors(remaining);
104
108
  setError(null);
105
109
  }, GridCell: GridCell, featureName: featureName, allIssues: snapshot.errors, valueDisplay: "form", row: snapshot.values, ...(hideTitle !== true &&
106
- section.title !== undefined && { sectionTitle: section.title }) }, field.field))) }), error !== null && (_jsx(Banner, { variant: "error", testId: "write-form-section-error", children: error })), onFooterAction === undefined && (_jsx(Card, { options: { framed: false, footerBordered: false, footerInset: false }, slots: { footer: submitButton }, testId: "write-form-section-submit-row" }))] }));
110
+ section.title !== undefined && { sectionTitle: section.title }) }, field.field))) }), error !== null && (_jsxs(Banner, { variant: "error", testId: "write-form-section-error", children: [error.message, _jsx(ErrorReference, { requestId: error.requestId })] })), onFooterAction === undefined && (_jsx(Card, { options: { framed: false, footerBordered: false, footerInset: false }, slots: { footer: submitButton }, testId: "write-form-section-submit-row" }))] }));
107
111
  // Section always flattens to a borderless divider when rendered inside
108
112
  // RenderEdit's own <Form> in tabs mode (hideTitle) — an unframed Card panel
109
113
  // stands in, like render-edit.tsx's tabs-mode fields/extension branches.
@@ -464,7 +464,7 @@ export function computeEditViewModel(input) {
464
464
  // better than the single-column default a stacked form keeps. Applies to
465
465
  // entityEdit tabs as well since fw#3134, not just projectionDetail.
466
466
  const defaultColumns = screen.layout.mode === "tabs" ? 2 : 1;
467
- const groups = sectionSpec.groups
467
+ const groups = ("groups" in sectionSpec ? sectionSpec.groups : undefined)
468
468
  ?.map((group) => ({ ...group, fields: group.fields.filter((f) => !isHiddenByExclusion(f)) }))
469
469
  .filter((group) => group.fields.length > 0)
470
470
  .map((group) => ({
@@ -157,7 +157,7 @@ export type EditFieldViewModel = {
157
157
  readonly rows?: number;
158
158
  };
159
159
  /** From TextFieldDef.format — `password` makes the renderer mask the input. */
160
- readonly format?: "email" | "url" | "phone" | "password" | "month";
160
+ readonly format?: "email" | "url" | "phone" | "password" | "month" | "iban";
161
161
  /** From TextFieldDef.autoComplete — HTML autocomplete token for the text input. */
162
162
  readonly autoComplete?: string;
163
163
  /** From TextFieldDef.writeOnly — value is `true` (set) / `null` (empty), never plaintext. */
@@ -156,6 +156,9 @@ export const kumikoDefaultTranslations = {
156
156
  "kumiko.screen-access-denied.title": "You do not have access to this page.",
157
157
  "kumiko.screen-missing.home": "Go to start page",
158
158
  "kumiko.screen-error.title": "This screen failed to render.",
159
+ "kumiko.error.reference": "Error ID: {id}",
160
+ "kumiko.error.copyReference": "Copy",
161
+ "kumiko.error.referenceCopied": "Copied!",
159
162
  "kumiko.screen-error.detail": "Screen: {screen}. {message}",
160
163
  "kumiko.screen-error.screen": "Screen: {screen}",
161
164
  "kumiko.widget.upload.uploading": "Uploading…",
@@ -280,6 +283,7 @@ export const kumikoDefaultTranslations = {
280
283
  "errors.validation.invalid_format.date": "Invalid date, e.g. 2026-10-20.",
281
284
  "errors.validation.invalid_format.datetime": "Invalid date and time, e.g. 2026-10-20T14:30.",
282
285
  "errors.validation.invalid_format.uuid": "Invalid ID, e.g. 123e4567-e89b-12d3-a456-426614174000.",
286
+ "errors.validation.invalid_format.iban": "Invalid IBAN, e.g. DE89 3704 0044 0532 0130 00.",
283
287
  "errors.validation.not_multiple_of": "Must be a multiple of {divisor}.",
284
288
  "errors.validation.unrecognized_keys": "Unknown fields.",
285
289
  "errors.validation.invalid_union": "Invalid value.",
package/dist/index.d.ts CHANGED
@@ -14,6 +14,7 @@ export type { CustomScreensMap, CustomScreensProviderProps } from "./app/custom-
14
14
  export { CustomScreensProvider, useCustomScreenComponent } from "./app/custom-screens.tsx";
15
15
  export type { DashboardBodyProps, DashboardBodyProviderProps } from "./app/dashboard-body.tsx";
16
16
  export { DashboardBodyProvider, useDashboardBody } from "./app/dashboard-body.tsx";
17
+ export { type ErrorNotice, ErrorReference, errorNoticeOf } from "./app/error-reference.tsx";
17
18
  export type { ExtensionFormRegistry, ExtensionFormSubmitHandler, ExtensionSubmitContext, ExtensionSubmitResult, } from "./app/extension-form-submit.tsx";
18
19
  export { ExtensionFormRegistryProvider, useExtensionFormSubmit, } from "./app/extension-form-submit.tsx";
19
20
  export type { ExtensionSectionComponent, ExtensionSectionProps, ExtensionSectionsMap, ExtensionSectionsProviderProps, } from "./app/extension-sections.tsx";
package/dist/index.js CHANGED
@@ -17,6 +17,7 @@ export { CONTENT_EDITOR_ELEMENT_ID, ContentEditorsProvider, TextareaContentEdito
17
17
  export { ContentPreview, substituteVariables } from "./app/content-preview.js";
18
18
  export { CustomScreensProvider, useCustomScreenComponent } from "./app/custom-screens.js";
19
19
  export { DashboardBodyProvider, useDashboardBody } from "./app/dashboard-body.js";
20
+ export { ErrorReference, errorNoticeOf } from "./app/error-reference.js";
20
21
  export { ExtensionFormRegistryProvider, useExtensionFormSubmit, } from "./app/extension-form-submit.js";
21
22
  export { ExtensionSectionsProvider, extensionSectionName, useExtensionSectionComponent, } from "./app/extension-sections.js";
22
23
  export { isAppSchema, toAppSchema } from "./app/feature-schema.js";
@@ -1,6 +1,8 @@
1
1
  import { useEffect, useRef } from "react";
2
2
  const sources = new Set();
3
3
  let confirmer;
4
+ // While a confirmed (or unguarded) leave runs, navigation calls nested inside it must not ask a second time.
5
+ let runningLeaveDepth = 0;
4
6
  export function registerUnsavedInputSource(source) {
5
7
  sources.add(source);
6
8
  return () => {
@@ -23,12 +25,21 @@ export function setLeaveConfirmer(next) {
23
25
  /** Runs `proceed` right away when nothing is dirty (or no dialog host is
24
26
  * mounted); otherwise asks the user first. */
25
27
  export function requestLeave(proceed, cancel = () => { }) {
26
- if (confirmer === undefined || !hasUnsavedInput()) {
27
- proceed();
28
+ if (runningLeaveDepth > 0 || confirmer === undefined || !hasUnsavedInput()) {
29
+ runLeave(proceed);
28
30
  // skip: nothing to lose, no question needed
29
31
  return;
30
32
  }
31
- confirmer({ proceed, cancel });
33
+ confirmer({ proceed: () => runLeave(proceed), cancel });
34
+ }
35
+ function runLeave(proceed) {
36
+ runningLeaveDepth += 1;
37
+ try {
38
+ proceed();
39
+ }
40
+ finally {
41
+ runningLeaveDepth -= 1;
42
+ }
32
43
  }
33
44
  /** `isDirty` is read lazily, at guard time, so a form can report "clean"
34
45
  * synchronously after a successful save — before React re-renders. */
@@ -0,0 +1,15 @@
1
+ export declare const CLIENT_ERRORS_PATH = "/api/client-errors";
2
+ export declare const MAX_CLIENT_ERROR_REPORTS = 5;
3
+ export type ClientErrorKind = "render" | "unhandled";
4
+ export type ClientErrorReport = {
5
+ readonly errorId?: string;
6
+ readonly kind: ClientErrorKind;
7
+ readonly error: unknown;
8
+ readonly screen?: string;
9
+ };
10
+ export type ClientErrorReporter = {
11
+ readonly report: (report: ClientErrorReport) => void;
12
+ /** Starts forwarding window error/unhandledrejection events; returns the stop function. */
13
+ readonly installGlobalHandlers: () => () => void;
14
+ };
15
+ export declare function createClientErrorReporter(): ClientErrorReporter;
@@ -0,0 +1,83 @@
1
+ import { createErrorId } from "../../components/error-id.js";
2
+ import { CSRF_HEADER_NAME, readCsrfToken } from "../../live/csrf.js";
3
+ export const CLIENT_ERRORS_PATH = "/api/client-errors";
4
+ export const MAX_CLIENT_ERROR_REPORTS = 5;
5
+ // Mirrors the server's strict schema; longer values would get the whole report rejected.
6
+ const LIMITS = { message: 500, stack: 4000, screen: 200, path: 300 };
7
+ function clip(value, max) {
8
+ return value.length > max ? value.slice(0, max) : value;
9
+ }
10
+ function toError(thrown) {
11
+ return thrown instanceof Error ? thrown : new Error(String(thrown));
12
+ }
13
+ // One handler pair per page: a repeated createKumikoApp (HMR, tests) must replace, not stack, the listeners.
14
+ let removeActiveGlobalHandlers;
15
+ export function createClientErrorReporter() {
16
+ const seenMessages = new Set();
17
+ function send(body) {
18
+ // skip: no fetch (native shell without polyfill) means nowhere to report to
19
+ if (typeof fetch !== "function")
20
+ return;
21
+ const csrf = readCsrfToken();
22
+ try {
23
+ void fetch(CLIENT_ERRORS_PATH, {
24
+ method: "POST",
25
+ keepalive: true,
26
+ credentials: "same-origin",
27
+ headers: {
28
+ "Content-Type": "application/json",
29
+ ...(csrf !== undefined && { [CSRF_HEADER_NAME]: csrf }),
30
+ },
31
+ body: JSON.stringify(body),
32
+ }).catch(() => undefined);
33
+ }
34
+ catch {
35
+ // Reporting must never throw into the error path it observes.
36
+ }
37
+ }
38
+ function report({ errorId, kind, error, screen }) {
39
+ // skip: per-page cap keeps an error loop from flooding the endpoint
40
+ if (seenMessages.size >= MAX_CLIENT_ERROR_REPORTS)
41
+ return;
42
+ const { message, stack } = toError(error);
43
+ // skip: duplicate of an already reported message
44
+ if (seenMessages.has(message))
45
+ return;
46
+ seenMessages.add(message);
47
+ const path = typeof location === "undefined" ? undefined : location.pathname;
48
+ send({
49
+ errorId: errorId ?? createErrorId(),
50
+ kind,
51
+ message: clip(message, LIMITS.message),
52
+ ...(stack !== undefined && stack !== "" && { stack: clip(stack, LIMITS.stack) }),
53
+ ...(screen !== undefined && { screen: clip(screen, LIMITS.screen) }),
54
+ ...(path !== undefined && { path: clip(path, LIMITS.path) }),
55
+ });
56
+ }
57
+ function installGlobalHandlers() {
58
+ if (typeof window === "undefined" || typeof window.addEventListener !== "function") {
59
+ return () => undefined;
60
+ }
61
+ const onError = (event) => {
62
+ // skip: resource-load failures arrive as plain Events without an error payload
63
+ if (event.error === undefined && !event.message)
64
+ return;
65
+ report({ kind: "unhandled", error: event.error ?? new Error(event.message) });
66
+ };
67
+ const onRejection = (event) => {
68
+ report({ kind: "unhandled", error: event.reason });
69
+ };
70
+ removeActiveGlobalHandlers?.();
71
+ window.addEventListener("error", onError);
72
+ window.addEventListener("unhandledrejection", onRejection);
73
+ const remove = () => {
74
+ window.removeEventListener("error", onError);
75
+ window.removeEventListener("unhandledrejection", onRejection);
76
+ if (removeActiveGlobalHandlers === remove)
77
+ removeActiveGlobalHandlers = undefined;
78
+ };
79
+ removeActiveGlobalHandlers = remove;
80
+ return remove;
81
+ }
82
+ return { report, installGlobalHandlers };
83
+ }
@@ -9,11 +9,19 @@ export declare function buildNavProviderMaps(clientFeatures: readonly ClientFeat
9
9
  readonly navProviders: ReadonlyMap<string, TreeChildrenSubscribe>;
10
10
  readonly navEntities: ReadonlyMap<string, readonly string[]>;
11
11
  };
12
- export type BoundaryErrorHandler = (error: Error, info: ErrorInfo) => void;
12
+ export type BoundaryErrorContext = {
13
+ readonly errorId: string;
14
+ /** Qualified name of the screen whose boundary caught the error; absent for the root boundary. */
15
+ readonly screen?: string;
16
+ };
17
+ export type BoundaryErrorHandler = (error: Error, info: ErrorInfo, context: BoundaryErrorContext) => void;
13
18
  export type CreateKumikoAppOptions = {
14
19
  /** Called when the screen or root error boundary catches an error, after it
15
20
  * was logged with console.error — attach telemetry here. */
16
21
  readonly onError?: BoundaryErrorHandler;
22
+ /** POST caught render errors and unhandled window errors to `/api/client-errors`
23
+ * (default true). A custom `onError` still runs in addition. */
24
+ readonly reportClientErrors?: boolean;
17
25
  /** App schema. Accepts AppSchema (multi-feature) or the legacy
18
26
  * FeatureSchema (single-feature) — toAppSchema() normalizes internally.
19
27
  *
@@ -12,6 +12,7 @@ import { useBrowserTokensApi } from "../tokens.js";
12
12
  import { UpdateChecker } from "../version/update-checker.js";
13
13
  import { AppSchemaFetchBoot } from "./app-schema-boot.js";
14
14
  import { createBrowserLocaleResolver } from "./browser-locale.js";
15
+ import { createClientErrorReporter } from "./client-error-reporter.js";
15
16
  import { stackWrappers } from "./client-plugin.js";
16
17
  import { WebDashboardBody } from "./dashboard-body.js";
17
18
  import { DocumentLangSync } from "./document-lang-sync.js";
@@ -338,8 +339,19 @@ export function createKumikoApp(options = {}) {
338
339
  }
339
340
  const localeResolver = options.locale ?? createBrowserLocaleResolver();
340
341
  const navAdapter = options.navAdapter ?? useBrowserNavApi;
342
+ const reporter = options.reportClientErrors === false ? undefined : createClientErrorReporter();
343
+ reporter?.installGlobalHandlers();
344
+ const handleBoundaryError = (error, info, context) => {
345
+ reporter?.report({
346
+ kind: "render",
347
+ error,
348
+ errorId: context.errorId,
349
+ ...(context.screen !== undefined && { screen: context.screen }),
350
+ });
351
+ options.onError?.(error, info, context);
352
+ };
341
353
  const root = createRoot(container);
342
- root.render(_jsx(ErrorBoundary, { scope: "root", ...(options.onError !== undefined && { onError: options.onError }), fallback: () => _jsx(RootErrorFallback, {}), children: _jsx(BoundaryErrorHandlerContext.Provider, { value: options.onError, children: _jsx(KumikoAppRoot, { initialApp: initialApp, screenQn: options.screenQn, ...(options.schemaUrl !== undefined && { schemaUrl: options.schemaUrl }), clientFeatures: clientFeatures, providers: providers, gates: gates, customScreens: customScreens, columnRenderers: columnRenderers, extensionSectionComponents: extensionSectionComponents, clientActions: clientActions, contentEditors: contentEditors, resolvers: resolvers, dispatcher: dispatcher, sessionEndedSignal: sessionEndedSignal, draftStorage: draftStorage, primitives: primitives, liveEvents: liveEvents, localeResolver: localeResolver, navAdapter: navAdapter, screenWidth: options.screenWidth, ...(options.translate !== undefined && { translate: options.translate }), ...(options.onRowClick !== undefined && { onRowClick: options.onRowClick }), ...(options.shell !== undefined && { shell: options.shell }) }) }) }));
354
+ root.render(_jsx(ErrorBoundary, { scope: "root", onError: handleBoundaryError, fallback: ({ errorId }) => _jsx(RootErrorFallback, { errorId: errorId }), children: _jsx(BoundaryErrorHandlerContext.Provider, { value: handleBoundaryError, children: _jsx(KumikoAppRoot, { initialApp: initialApp, screenQn: options.screenQn, ...(options.schemaUrl !== undefined && { schemaUrl: options.schemaUrl }), clientFeatures: clientFeatures, providers: providers, gates: gates, customScreens: customScreens, columnRenderers: columnRenderers, extensionSectionComponents: extensionSectionComponents, clientActions: clientActions, contentEditors: contentEditors, resolvers: resolvers, dispatcher: dispatcher, sessionEndedSignal: sessionEndedSignal, draftStorage: draftStorage, primitives: primitives, liveEvents: liveEvents, localeResolver: localeResolver, navAdapter: navAdapter, screenWidth: options.screenWidth, ...(options.translate !== undefined && { translate: options.translate }), ...(options.onRowClick !== undefined && { onRowClick: options.onRowClick }), ...(options.shell !== undefined && { shell: options.shell }) }) }) }));
343
355
  return { root };
344
356
  }
345
357
  // TokensBoot uses the browser-backed TokensApi hook (class-based
@@ -494,5 +506,7 @@ function RoutedScreen({ app, fallbackQn, translate, onRowClick, }) {
494
506
  }
495
507
  // KumikoScreen takes a single-feature schema: the feature owning the active screen.
496
508
  const schemaForScreen = feature;
497
- return (_jsx(ErrorBoundary, { scope: "screen", ...(onBoundaryError !== undefined && { onError: onBoundaryError }), fallback: ({ error, reset }) => (_jsx(ScreenErrorFallback, { screen: qn, error: error, onReset: reset })), children: _jsx(KumikoScreen, { schema: schemaForScreen, qn: qn, ...(translate !== undefined && { translate }), ...(entityId !== undefined && { entityId }), onRowClick: effectiveOnRowClick, ...(effectiveOnCopyLink !== undefined && { onCopyLink: effectiveOnCopyLink }) }) }, `${qn}/${entityId ?? ""}`));
509
+ return (_jsx(ErrorBoundary, { scope: "screen", ...(onBoundaryError !== undefined && {
510
+ onError: (error, info, context) => onBoundaryError(error, info, { ...context, screen: qn }),
511
+ }), fallback: ({ error, errorId, reset }) => (_jsx(ScreenErrorFallback, { screen: qn, error: error, errorId: errorId, onReset: reset })), children: _jsx(KumikoScreen, { schema: schemaForScreen, qn: qn, ...(translate !== undefined && { translate }), ...(entityId !== undefined && { entityId }), onRowClick: effectiveOnRowClick, ...(effectiveOnCopyLink !== undefined && { onCopyLink: effectiveOnCopyLink }) }) }, `${qn}/${entityId ?? ""}`));
498
512
  }
@@ -3,6 +3,7 @@ import { normalizeListColumn } from "@cosmicdrift/kumiko-framework/ui-types";
3
3
  import { DASHBOARD_STAT_TONES } from "@cosmicdrift/kumiko-types/screen";
4
4
  import { isOneOf } from "@cosmicdrift/kumiko-types/type-guards";
5
5
  import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, } from "react";
6
+ import { ErrorReference } from "../../app/error-reference.js";
6
7
  import { currencyDecimals, EMPTY_VALUE_PLACEHOLDER } from "../../headless/index.js";
7
8
  import { dispatcherErrorText, EmbeddedScreenProvider, evalVisibleWhen, extensionSectionName, HeaderActionsBar, KumikoScreen, lastSegment, useEmbeddedScreen, useExtensionSectionComponent, useLocale, useNav, usePageHeaderCompact, usePageHeaderSlotAvailable, usePrimitives, useQuery, useRecordActions, useTranslation, useUserRoles, } from "../../index.js";
8
9
  import { cn } from "../lib/cn.js";
@@ -865,7 +866,7 @@ export function WebDashboardBody({ featureName, screen, translate, }) {
865
866
  // The shell header owns the title; the badge and the actions sit in it there.
866
867
  const badgeInHeader = scopeBadge !== null && usesPageHeaderSlot;
867
868
  const showPageHeader = usesPageHeaderSlot && (badgeInHeader || recordTitle !== undefined || headerActionsBar !== null);
868
- return (_jsx(PanelLoadedContext.Provider, { value: reportLoaded, children: _jsx(DashboardQueryContext.Provider, { value: queryRuntime, children: _jsxs(PageSection, { className: "flex flex-col gap-4", testId: `dashboard-${screen.id}`, children: [showPageHeader && (_jsx(PageHeader, { ...(recordTitle !== undefined && { title: recordTitle }), ...(badgeInHeader && { status: scopeBadge }), ...(headerActionsBar !== null && { actions: headerActionsBar }) })), _jsxs("div", { className: "flex flex-wrap items-end justify-between gap-3", children: [_jsxs("div", { className: "flex min-w-0 flex-wrap items-center gap-2", children: [!usesPageHeaderSlot && recordTitle !== undefined && (_jsx(Heading, { variant: "page", testId: `dashboard-${screen.id}-record-title`, children: recordTitle })), !badgeInHeader && scopeBadge, picker] }), _jsxs("div", { className: "flex flex-wrap items-center gap-3", children: [!usesPageHeaderSlot && headerActionsBar, screen.showUpdatedAt !== false && (_jsx("span", { "data-testid": `dashboard-${screen.id}-updated-at`, className: "text-xs tabular-nums text-muted-foreground", children: t("kumiko.dashboard.updated-at", { time: formatDateTime(loadedAtMs) }) })), rangeControl] })] }), actionError !== null && (_jsx(Banner, { variant: "error", testId: "render-edit-action-error", children: actionError })), recordQuery.error !== null && recordRow === null && (_jsxs("div", { role: "alert", "data-testid": `dashboard-${screen.id}-record-error`, className: "flex flex-col items-start gap-2 text-sm", children: [_jsx("span", { className: "text-status-bad", children: dispatcherErrorText(recordQuery.error, t) }), _jsx(Button, { variant: "secondary", onClick: () => void refetchRecord(), children: t("kumiko.dashboard.panel.error.retry") })] })), description !== undefined && (_jsx(Text, { variant: "muted", testId: `dashboard-${screen.id}-description`, children: description })), scope?.notice !== undefined && (_jsx(Banner, { variant: "info", testId: `dashboard-${screen.id}-scope-notice`, children: effectiveTranslate(scope.notice) })), _jsxs(VisibilityQueryContext.Provider, { value: gateResults.results, children: [_jsx(VisibilityGateQueries, { gates: visibilityGates, onResult: gateResults.report, onForget: gateResults.forget }), _jsx("div", { className: "grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4", children: screen.panels.map((panel) => {
869
+ return (_jsx(PanelLoadedContext.Provider, { value: reportLoaded, children: _jsx(DashboardQueryContext.Provider, { value: queryRuntime, children: _jsxs(PageSection, { className: "flex flex-col gap-4", testId: `dashboard-${screen.id}`, children: [showPageHeader && (_jsx(PageHeader, { ...(recordTitle !== undefined && { title: recordTitle }), ...(badgeInHeader && { status: scopeBadge }), ...(headerActionsBar !== null && { actions: headerActionsBar }) })), _jsxs("div", { className: "flex flex-wrap items-end justify-between gap-3", children: [_jsxs("div", { className: "flex min-w-0 flex-wrap items-center gap-2", children: [!usesPageHeaderSlot && recordTitle !== undefined && (_jsx(Heading, { variant: "page", testId: `dashboard-${screen.id}-record-title`, children: recordTitle })), !badgeInHeader && scopeBadge, picker] }), _jsxs("div", { className: "flex flex-wrap items-center gap-3", children: [!usesPageHeaderSlot && headerActionsBar, screen.showUpdatedAt !== false && (_jsx("span", { "data-testid": `dashboard-${screen.id}-updated-at`, className: "text-xs tabular-nums text-muted-foreground", children: t("kumiko.dashboard.updated-at", { time: formatDateTime(loadedAtMs) }) })), rangeControl] })] }), actionError !== null && (_jsxs(Banner, { variant: "error", testId: "render-edit-action-error", children: [actionError.message, _jsx(ErrorReference, { requestId: actionError.requestId })] })), recordQuery.error !== null && recordRow === null && (_jsxs("div", { role: "alert", "data-testid": `dashboard-${screen.id}-record-error`, className: "flex flex-col items-start gap-2 text-sm", children: [_jsx("span", { className: "text-status-bad", children: dispatcherErrorText(recordQuery.error, t) }), _jsx(Button, { variant: "secondary", onClick: () => void refetchRecord(), children: t("kumiko.dashboard.panel.error.retry") })] })), description !== undefined && (_jsx(Text, { variant: "muted", testId: `dashboard-${screen.id}-description`, children: description })), scope?.notice !== undefined && (_jsx(Banner, { variant: "info", testId: `dashboard-${screen.id}-scope-notice`, children: effectiveTranslate(scope.notice) })), _jsxs(VisibilityQueryContext.Provider, { value: gateResults.results, children: [_jsx(VisibilityGateQueries, { gates: visibilityGates, onResult: gateResults.report, onForget: gateResults.forget }), _jsx("div", { className: "grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4", children: screen.panels.map((panel) => {
869
870
  if (panel.kind === "screen") {
870
871
  return (_jsx(ScreenPanelTile, { panel: panel, featureName: featureName, translate: effectiveTranslate }, panel.id));
871
872
  }
@@ -1,2 +1,4 @@
1
- import type { ReactNode } from "react";
2
- export declare function RootErrorFallback(): ReactNode;
1
+ import { type ReactNode } from "react";
2
+ export declare function RootErrorFallback({ errorId }: {
3
+ readonly errorId?: string;
4
+ }): ReactNode;
@@ -1,10 +1,30 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { ROOT_ERROR_FALLBACK_TEST_ID } from "@cosmicdrift/kumiko-types/error-boundary";
3
- // Renders outside every provider, so no i18n/theme/primitives: plain DOM + inline text.
3
+ import { Check, Copy } from "lucide-react";
4
+ import { useState } from "react";
5
+ // Renders outside every provider and possibly without loaded CSS, so no i18n/theme/primitives: plain DOM, inline text and inline styles (lucide icons are self-contained SVG).
4
6
  const ROOT_ERROR_TEXTS = {
5
- de: { title: "Etwas ist schiefgelaufen.", reload: "Seite neu laden" },
6
- en: { title: "Something went wrong.", reload: "Reload page" },
7
- es: { title: "Algo salió mal.", reload: "Recargar la página" },
7
+ de: {
8
+ title: "Etwas ist schiefgelaufen.",
9
+ reload: "Seite neu laden",
10
+ reference: "Fehler-ID",
11
+ copy: "Kopieren",
12
+ copied: "Kopiert!",
13
+ },
14
+ en: {
15
+ title: "Something went wrong.",
16
+ reload: "Reload page",
17
+ reference: "Error ID",
18
+ copy: "Copy",
19
+ copied: "Copied!",
20
+ },
21
+ es: {
22
+ title: "Algo salió mal.",
23
+ reload: "Recargar la página",
24
+ reference: "ID de error",
25
+ copy: "Copiar",
26
+ copied: "¡Copiado!",
27
+ },
8
28
  };
9
29
  function rootErrorTexts() {
10
30
  const language = typeof navigator === "undefined" ? "en" : navigator.language;
@@ -15,7 +35,60 @@ function rootErrorTexts() {
15
35
  return ROOT_ERROR_TEXTS.es;
16
36
  return ROOT_ERROR_TEXTS.en;
17
37
  }
18
- export function RootErrorFallback() {
38
+ const containerStyle = {
39
+ boxSizing: "border-box",
40
+ minHeight: "100vh",
41
+ display: "flex",
42
+ flexDirection: "column",
43
+ alignItems: "center",
44
+ justifyContent: "center",
45
+ gap: "16px",
46
+ padding: "24px",
47
+ textAlign: "center",
48
+ fontFamily: "system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif",
49
+ color: "#1a1a1a",
50
+ background: "#ffffff",
51
+ };
52
+ const titleStyle = { margin: 0, fontSize: "20px", fontWeight: 600 };
53
+ const referenceStyle = {
54
+ display: "flex",
55
+ alignItems: "center",
56
+ flexWrap: "wrap",
57
+ justifyContent: "center",
58
+ gap: "8px",
59
+ fontSize: "13px",
60
+ color: "#555555",
61
+ };
62
+ const errorIdStyle = { whiteSpace: "nowrap" };
63
+ const buttonStyle = {
64
+ display: "inline-flex",
65
+ alignItems: "center",
66
+ gap: "6px",
67
+ padding: "6px 12px",
68
+ fontFamily: "inherit",
69
+ fontSize: "14px",
70
+ fontWeight: 500,
71
+ lineHeight: "20px",
72
+ borderRadius: "6px",
73
+ border: "1px solid #e4e4e7",
74
+ background: "#ffffff",
75
+ color: "#1a1a1a",
76
+ boxShadow: "0 1px 2px rgba(0, 0, 0, 0.05)",
77
+ cursor: "pointer",
78
+ };
79
+ export function RootErrorFallback({ errorId }) {
19
80
  const texts = rootErrorTexts();
20
- return (_jsxs("div", { role: "alert", "data-testid": ROOT_ERROR_FALLBACK_TEST_ID, children: [_jsx("p", { children: texts.title }), _jsx("button", { type: "button", onClick: () => window.location.reload(), children: texts.reload })] }));
81
+ const [copied, setCopied] = useState(false);
82
+ async function copyErrorId() {
83
+ if (errorId === undefined)
84
+ return;
85
+ try {
86
+ await navigator.clipboard.writeText(errorId);
87
+ setCopied(true);
88
+ }
89
+ catch {
90
+ // Clipboard denied or unavailable: the id stays visible and selectable.
91
+ }
92
+ }
93
+ return (_jsxs("div", { role: "alert", "data-testid": ROOT_ERROR_FALLBACK_TEST_ID, style: containerStyle, children: [_jsx("p", { style: titleStyle, children: texts.title }), errorId !== undefined && (_jsxs("div", { style: referenceStyle, "data-testid": `${ROOT_ERROR_FALLBACK_TEST_ID}-reference`, children: [_jsxs("span", { children: [texts.reference, ": ", _jsx("span", { style: errorIdStyle, children: errorId })] }), _jsxs("button", { type: "button", style: buttonStyle, "data-testid": `${ROOT_ERROR_FALLBACK_TEST_ID}-copy`, onClick: () => void copyErrorId(), children: [copied ? (_jsx(Check, { size: 14, "aria-hidden": "true" })) : (_jsx(Copy, { size: 14, "aria-hidden": "true" })), copied ? texts.copied : texts.copy] })] })), _jsx("button", { type: "button", style: buttonStyle, onClick: () => window.location.reload(), children: texts.reload })] }));
21
94
  }
@@ -1,6 +1,7 @@
1
1
  import type { ReactNode } from "react";
2
- export declare function ScreenErrorFallback({ screen, error, onReset, }: {
2
+ export declare function ScreenErrorFallback({ screen, error, errorId, onReset, }: {
3
3
  readonly screen: string;
4
4
  readonly error: Error;
5
+ readonly errorId: string;
5
6
  readonly onReset: () => void;
6
7
  }): ReactNode;
@@ -1,5 +1,6 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { SCREEN_ERROR_FALLBACK_TEST_ID } from "@cosmicdrift/kumiko-types/error-boundary";
3
+ import { ErrorReference } from "../../app/error-reference.js";
3
4
  import { usePrimitives, useTranslation } from "../../index.js";
4
5
  // Opt-in: a bundle that leaves NODE_ENV unset must not leak error messages to end users.
5
6
  function showsErrorDetails() {
@@ -8,10 +9,10 @@ function showsErrorDetails() {
8
9
  const nodeEnv = process.env.NODE_ENV;
9
10
  return nodeEnv !== undefined && nodeEnv !== "" && nodeEnv !== "production";
10
11
  }
11
- export function ScreenErrorFallback({ screen, error, onReset, }) {
12
+ export function ScreenErrorFallback({ screen, error, errorId, onReset, }) {
12
13
  const { Banner, Button } = usePrimitives();
13
14
  const t = useTranslation();
14
- return (_jsx(Banner, { variant: "error", padded: true, testId: SCREEN_ERROR_FALLBACK_TEST_ID, title: t("kumiko.screen-error.title"), actions: _jsx(Button, { variant: "secondary", onClick: onReset, testId: `${SCREEN_ERROR_FALLBACK_TEST_ID}-reset`, children: t("kumiko.actions.reload") }), children: showsErrorDetails()
15
- ? t("kumiko.screen-error.detail", { screen, message: error.message })
16
- : t("kumiko.screen-error.screen", { screen }) }));
15
+ return (_jsxs(Banner, { variant: "error", padded: true, testId: SCREEN_ERROR_FALLBACK_TEST_ID, title: t("kumiko.screen-error.title"), actions: _jsx(Button, { variant: "secondary", onClick: onReset, testId: `${SCREEN_ERROR_FALLBACK_TEST_ID}-reset`, children: t("kumiko.actions.reload") }), children: [showsErrorDetails()
16
+ ? t("kumiko.screen-error.detail", { screen, message: error.message })
17
+ : t("kumiko.screen-error.screen", { screen }), _jsx(ErrorReference, { requestId: errorId, testId: `${SCREEN_ERROR_FALLBACK_TEST_ID}-reference` })] }));
17
18
  }
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { X } from "lucide-react";
3
3
  import { useCallback, useMemo } from "react";
4
- import { useNav, usePrimitives, useTranslation } from "../../index.js";
4
+ import { requestLeave, useNav, usePrimitives, useTranslation } from "../../index.js";
5
5
  import { clearTargetSearchParams, parseTargetFromSearchParams } from "./target-url.js";
6
6
  function EditorPanelInner({ target, resolvers, onClose, }) {
7
7
  const { JsonView } = usePrimitives();
@@ -20,7 +20,7 @@ export function EditorPanel({ resolvers }) {
20
20
  // sind (nav-Impl liefert plain-record für genau diesen check).
21
21
  const target = useMemo(() => parseTargetFromSearchParams(nav.searchParams), [nav.searchParams]);
22
22
  const handleClose = useCallback(() => {
23
- nav.setSearchParams(clearTargetSearchParams(nav.searchParams));
23
+ requestLeave(() => nav.setSearchParams(clearTargetSearchParams(nav.searchParams)));
24
24
  }, [nav]);
25
25
  return (_jsx("div", { "data-kumiko-layout": "editor-main", className: "flex-1 overflow-y-auto", children: target === undefined ? (_jsx(EditorEmptyState, {})) : (_jsx(EditorPanelInner, { target: target, resolvers: resolvers, onClose: handleClose })) }));
26
26
  }
@@ -16,7 +16,7 @@
16
16
  // die URL via nav.setSearchParams UND ruft den globalen dispatchTarget für
17
17
  // Test-Listener-Kompatibilität.
18
18
  import { useCallback } from "react";
19
- import { useNav } from "../../index.js";
19
+ import { requestLeave, useNav } from "../../index.js";
20
20
  import { serializeTarget } from "./target-url.js";
21
21
  let testListener;
22
22
  const subscribers = new Set();
@@ -55,7 +55,9 @@ export function subscribeTargetDispatches(fn) {
55
55
  export function useDispatchTarget() {
56
56
  const nav = useNav();
57
57
  return useCallback((target) => {
58
- nav.setSearchParams(serializeTarget(target, nav.searchParams));
59
- dispatchTarget(target);
58
+ requestLeave(() => {
59
+ nav.setSearchParams(serializeTarget(target, nav.searchParams));
60
+ dispatchTarget(target);
61
+ });
60
62
  }, [nav]);
61
63
  }
@@ -901,12 +901,15 @@ function useRowActionTrigger(row) {
901
901
  catch (e) {
902
902
  // Surfacing statt schlucken: ein verschluckter Write-Fehler sah für
903
903
  // den User wie "nichts passiert" aus (Prod-Bug 2026-06-07).
904
- const docsUrl = e instanceof WriteFailedError ? e.dispatcherError.docsUrl : undefined;
904
+ const dispatcherError = e instanceof WriteFailedError ? e.dispatcherError : undefined;
905
+ const docsUrl = dispatcherError?.docsUrl;
906
+ const requestId = dispatcherError?.requestId;
905
907
  toast({
906
908
  title: t("kumiko.rowAction.failed"),
907
909
  description: e instanceof Error ? e.message : String(e),
908
910
  variant: "bad",
909
911
  ...(docsUrl !== undefined && { docsUrl }),
912
+ ...(requestId !== undefined && { requestId }),
910
913
  });
911
914
  }
912
915
  finally {
@@ -7,6 +7,7 @@ export type ToastOptions = {
7
7
  readonly variant?: ToastVariant;
8
8
  readonly docsUrl?: string;
9
9
  readonly docsLinkLabel?: string;
10
+ readonly requestId?: string;
10
11
  };
11
12
  type ToastApi = {
12
13
  readonly toast: (opts: ToastOptions) => void;
@@ -12,6 +12,7 @@ import { X } from "lucide-react";
12
12
  import { createContext, useCallback, useContext, useId, useMemo, useRef, useState, } from "react";
13
13
  import { useTranslation } from "../../index.js";
14
14
  import { cn } from "../lib/cn.js";
15
+ import { CopyButton } from "./share-actions.js";
15
16
  const ToastContext = createContext(null);
16
17
  export function useToast() {
17
18
  const ctx = useContext(ToastContext);
@@ -52,6 +53,11 @@ const TONE_CLASS = {
52
53
  critical: "border-status-critical/40 bg-status-critical/15 text-status-critical",
53
54
  muted: "border bg-background text-foreground",
54
55
  };
56
+ function ToastErrorReference({ requestId }) {
57
+ const t = useTranslation();
58
+ const reference = t("kumiko.error.reference", { id: requestId });
59
+ return (_jsxs("div", { className: "flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 text-xs", "data-testid": "toast-error-reference", children: [_jsx("span", { className: "min-w-0 truncate whitespace-nowrap select-all opacity-90", title: reference, children: reference }), _jsx(CopyButton, { text: requestId, label: t("kumiko.error.copyReference"), copiedLabel: t("kumiko.error.referenceCopied"), variant: "secondary", className: "min-h-0 h-7 shrink-0", testId: "toast-error-reference-copy" })] }));
60
+ }
55
61
  function ToastItem({ entry, onClose, }) {
56
62
  const t = useTranslation();
57
63
  const learnMore = entry.docsLinkLabel ?? t("kumiko.toast.learn-more");
@@ -59,5 +65,5 @@ function ToastItem({ entry, onClose, }) {
59
65
  return (_jsxs(Primitive.Root, { className: cn(rootClass, variantClass), onOpenChange: (open) => {
60
66
  if (!open)
61
67
  onClose();
62
- }, children: [_jsxs("div", { className: "grid gap-1", children: [_jsx(Primitive.Title, { className: "text-sm font-semibold", children: entry.title }), entry.description !== undefined && (_jsx(Primitive.Description, { className: "text-sm opacity-90", children: entry.description })), entry.docsUrl !== undefined && (_jsx(Primitive.Action, { altText: learnMore, asChild: true, children: _jsxs("a", { href: entry.docsUrl, target: "_blank", rel: "noopener noreferrer", className: cn("text-xs underline opacity-90 hover:opacity-100", "focus:outline-none focus:ring-1 focus:ring-current rounded"), children: [learnMore, " \u2192"] }) }))] }), _jsx(Primitive.Close, { className: cn("absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity", "hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-1", "group-hover:opacity-100"), "aria-label": t("kumiko.dialog.close"), children: _jsx(X, { className: "h-4 w-4" }) })] }));
68
+ }, children: [_jsxs("div", { className: "grid min-w-0 gap-1", children: [_jsx(Primitive.Title, { className: "text-sm font-semibold", children: entry.title }), entry.description !== undefined && (_jsx(Primitive.Description, { className: "text-sm opacity-90", children: entry.description })), entry.requestId !== undefined && _jsx(ToastErrorReference, { requestId: entry.requestId }), entry.docsUrl !== undefined && (_jsx(Primitive.Action, { altText: learnMore, asChild: true, children: _jsxs("a", { href: entry.docsUrl, target: "_blank", rel: "noopener noreferrer", className: cn("text-xs underline opacity-90 hover:opacity-100", "focus:outline-none focus:ring-1 focus:ring-current rounded"), children: [learnMore, " \u2192"] }) }))] }), _jsx(Primitive.Close, { className: cn("absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity", "hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-1", "group-hover:opacity-100"), "aria-label": t("kumiko.dialog.close"), children: _jsx(X, { className: "h-4 w-4" }) })] }));
63
69
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer",
3
- "version": "0.382.0",
3
+ "version": "0.383.1",
4
4
  "description": "React renderer for Kumiko screens. The root export holds the platform-agnostic logic (primitives contract, hooks, KumikoScreen, navigation and SSE abstractions); ./headless the DOM-free UI logic, ./live the HTTP dispatcher, ./web the browser bindings (HTML primitives, history navigation, EventSource live events, createKumikoApp).",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -48,8 +48,8 @@
48
48
  }
49
49
  },
50
50
  "dependencies": {
51
- "@cosmicdrift/kumiko-framework": "0.382.0",
52
- "@cosmicdrift/kumiko-types": "0.382.0",
51
+ "@cosmicdrift/kumiko-framework": "0.383.1",
52
+ "@cosmicdrift/kumiko-types": "0.383.1",
53
53
  "@radix-ui/react-dialog": "^1.1.15",
54
54
  "@radix-ui/react-dropdown-menu": "^2.1.16",
55
55
  "@radix-ui/react-label": "^2.1.8",
@@ -92,8 +92,8 @@
92
92
  }
93
93
  },
94
94
  "devDependencies": {
95
- "@cosmicdrift/kumiko-locale-de": "0.382.0",
96
- "@cosmicdrift/kumiko-testing": "0.382.0",
95
+ "@cosmicdrift/kumiko-locale-de": "0.383.1",
96
+ "@cosmicdrift/kumiko-testing": "0.383.1",
97
97
  "@tailwindcss/cli": "^4.3.0",
98
98
  "@testing-library/react": "^16.3.2",
99
99
  "@testing-library/user-event": "^14.6.1",
package/src/changes.json CHANGED
@@ -1,4 +1,35 @@
1
1
  [
2
+ {
3
+ "version": "0.383.0",
4
+ "type": "improvement",
5
+ "title": "projectionDetail metrics render their own label",
6
+ "detail": "The metrics band reads the label from each metric entry instead of falling back to `fieldLabels` or the column name."
7
+ },
8
+ {
9
+ "version": "0.383.0",
10
+ "type": "improvement",
11
+ "title": "Error reference with copy button in toasts, banners and fallbacks",
12
+ "detail": "A server error that carries a request id now shows \"Error ID: <id>\" with a copy button in toasts, action error banners, the write-form error section, query error banners and list empty states. The root and screen error fallbacks show a per-error id the same way; the root fallback is styled with inline styles so it stays readable without CSS. New exports ErrorReference, ErrorNotice and errorNoticeOf; New keys kumiko.error.reference, kumiko.error.copyReference and kumiko.error.referenceCopied in all locale packages."
13
+ },
14
+ {
15
+ "version": "0.383.0",
16
+ "type": "breaking",
17
+ "title": "HeaderActionsBar onError receives an ErrorNotice instead of a string",
18
+ "detail": "The onError prop of HeaderActionsBar is now (notice: ErrorNotice | null) => void, where ErrorNotice is { message, requestId? }, so the error reference can be shown next to the message.",
19
+ "migration": "change the handler to read notice.message (and notice.requestId); a handler that stores the string only needs `notice?.message ?? null`"
20
+ },
21
+ {
22
+ "version": "0.383.0",
23
+ "type": "improvement",
24
+ "title": "createKumikoApp reports browser errors to POST /api/client-errors",
25
+ "detail": "New option reportClientErrors (default true) sends error-boundary errors (kind render) and window error and unhandledrejection events (kind unhandled) to /api/client-errors with the same errorId the fallback shows, at most 5 reports per page load, identical messages once, path without query and hash, CSRF header when a token is present. A custom onError runs in addition and receives { errorId, screen? } as third argument. Set reportClientErrors: false to opt out."
26
+ },
27
+ {
28
+ "version": "0.383.0",
29
+ "type": "fix",
30
+ "title": "Global navigation with search params asks before dropping unsaved input",
31
+ "detail": "navigateWithReturnTo, navigateToReturn, the metric tab navigation, closing the editor panel and the target resolver now go through the leave guard, and a requestLeave nested in a confirmed leave no longer asks twice. List filters and sorting stay unguarded because they do not unmount a form. The mail designer registers its draft as unsaved input, so link and browser navigation ask before discarding it."
32
+ },
2
33
  {
3
34
  "version": "0.381.0",
4
35
  "type": "improvement",