@cosmicdrift/kumiko-renderer 0.391.1 → 0.392.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.
Files changed (35) hide show
  1. package/dist/app/action-form-body.d.ts +3 -5
  2. package/dist/app/action-form-body.js +8 -1
  3. package/dist/app/header-actions-bar.d.ts +3 -1
  4. package/dist/app/header-actions-bar.js +4 -5
  5. package/dist/app/header-actions.d.ts +5 -0
  6. package/dist/app/header-actions.js +11 -0
  7. package/dist/app/kumiko-screen.js +13 -7
  8. package/dist/app/projection-detail-header-model.d.ts +4 -3
  9. package/dist/app/projection-detail-header-model.js +33 -2
  10. package/dist/app/projection-detail-header-region.js +2 -0
  11. package/dist/app/projection-detail-header.js +5 -4
  12. package/dist/app/row-actions.d.ts +1 -1
  13. package/dist/app/row-actions.js +21 -10
  14. package/dist/app/screen-shared.d.ts +7 -0
  15. package/dist/app/screen-shared.js +12 -0
  16. package/dist/components/related-list-section.js +8 -2
  17. package/dist/headless/view-model/edit.js +9 -0
  18. package/dist/headless/view-model/types.d.ts +2 -1
  19. package/dist/hooks/live-query-entities.d.ts +6 -0
  20. package/dist/hooks/live-query-entities.js +44 -0
  21. package/dist/hooks/use-query.d.ts +1 -1
  22. package/dist/hooks/use-query.js +7 -17
  23. package/dist/primitives.d.ts +4 -2
  24. package/dist/primitives.js +2 -0
  25. package/dist/web/app/dashboard-body.js +1 -0
  26. package/dist/web/layout/shell-breadcrumb.d.ts +2 -0
  27. package/dist/web/layout/shell-breadcrumb.js +11 -0
  28. package/dist/web/layout/shell-header.d.ts +2 -1
  29. package/dist/web/layout/shell-header.js +12 -16
  30. package/dist/web/layout/workspace-shell.js +1 -1
  31. package/dist/web/primitives/data-table/data-cell-props.d.ts +1 -1
  32. package/dist/web/primitives/data-table-cell.js +2 -0
  33. package/dist/web/widgets/infinity-list.js +7 -6
  34. package/package.json +5 -5
  35. package/src/changes.json +42 -0
@@ -32,11 +32,9 @@ export declare function ActionFormBody({ schema, screen, translate, entityId, in
32
32
  * from the clicked row, applied on top of searchParams through the same
33
33
  * renderableFields/sensitive gates (see mergeSearchParamsIntoInitial). */
34
34
  readonly initialOverrides?: Readonly<Record<string, unknown>>;
35
- /** Drawer-hosted usage (toolbarAction kind:"drawer"): called
36
- * instead of the redirect-based navigation on successful submit, so the
37
- * host closes the drawer + refetches its list regardless of whether
38
- * `screen.redirect` is set — a full-page redirect would navigate away
39
- * from the list the drawer sits on top of. */
35
+ /** Drawer-hosted usage: called on successful submit so the host closes the
36
+ * drawer + refetches its list; a declared `screen.redirect` then navigates
37
+ * (without returnTo, a drawer is no navigation target). */
40
38
  readonly onSuccess?: () => void;
41
39
  /** Drawer-hosted usage: replaces the cancelTarget/redirect-based Cancel
42
40
  * handler so Cancel closes the drawer instead of navigating. */
@@ -20,7 +20,7 @@ import { qualifyScreenId } from "./qualify-screen-id.js";
20
20
  import { QueryLoadingBanner } from "./query-state-banners.js";
21
21
  import { navigateToReturn, navigateToReturnOr, useReturnTarget } from "./return-to.js";
22
22
  import { screenAccessAllows } from "./screen-access.js";
23
- import { formatSummaryParams, navigateToRedirectTarget, redirectScreenTarget, resolveActionFormSummary, resolveRedirectTarget, resolveSuccessLinks, resolveSuccessMessageParams, returnToWinsOverRedirect, ScreenAccessDeniedBanner, ScreenMissingState, } from "./screen-shared.js";
23
+ import { formatSummaryParams, navigateAfterDrawerSubmit, navigateToRedirectTarget, redirectScreenTarget, resolveActionFormSummary, resolveRedirectTarget, resolveSuccessLinks, resolveSuccessMessageParams, returnToWinsOverRedirect, ScreenAccessDeniedBanner, ScreenMissingState, } from "./screen-shared.js";
24
24
  export function useDrawerAction(schema) {
25
25
  const [drawerState, setDrawerState] = useState(null);
26
26
  const drawerAction = drawerState?.action ?? null;
@@ -182,6 +182,13 @@ export function ActionFormBody({ schema, screen, translate, entityId, initialOve
182
182
  return;
183
183
  if (onSuccess !== undefined) {
184
184
  onSuccess();
185
+ navigateAfterDrawerSubmit({
186
+ nav,
187
+ redirect: screen.redirect,
188
+ resultData: result.data,
189
+ schema,
190
+ appFeatures,
191
+ });
185
192
  return;
186
193
  }
187
194
  if (screen.redirect === undefined) {
@@ -2,9 +2,11 @@ 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
4
  import type { ErrorNotice } from "./error-reference.tsx";
5
- export declare function HeaderActionsBar({ actions, Button, Dialog, ActionOverflowMenu, onError, collapseAfterPrimary, compact, preferredPrimaryId, PageHeader, overflowTestId, }: {
5
+ export declare function HeaderActionsBar({ actions, Button, Dialog, ActionOverflowMenu, onError, collapseAfterPrimary, compact, preferredPrimaryId, inlineActionIds, PageHeader, overflowTestId, }: {
6
6
  /** `primaryAction` of the screen: wins over the edit/primary-style precedence. */
7
7
  readonly preferredPrimaryId?: string;
8
+ /** `inlineActions` of the screen: stay buttons next to the primary instead of the menu. */
9
+ readonly inlineActionIds?: readonly string[];
8
10
  /** Header slot layout: only the primary action stays a button, the rest goes into the menu. */
9
11
  readonly collapseAfterPrimary?: boolean;
10
12
  /** Phone-width shell header: an icon-only primary button, the rest in the shell's overflow menu. */
@@ -2,14 +2,14 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
2
2
  import { RenderEditActionButton } from "../components/render-edit-action-button.js";
3
3
  import { useActionMenuItems } from "../components/use-action-menu-items.js";
4
4
  import { useTranslation } from "../i18n.js";
5
- import { compactHeaderPrimary, orderPlainHeaderActions, primaryHeaderAction, } from "./header-actions.js";
5
+ import { compactHeaderPrimary, orderPlainHeaderActions, splitHeaderActions, } from "./header-actions.js";
6
6
  // Header actions bar: <=2 actions render as plain buttons; >2 with an
7
7
  // `ActionOverflowMenu` primitive collapse to the primary button plus a menu
8
8
  // for the rest — same rule as RowActionsCell for table rows. Owns confirm
9
9
  // state for menu items: RenderEditActionButton already owns a per-button
10
10
  // confirm dialog for the always-visible primary action, but a menu item has
11
11
  // no button of its own to carry one.
12
- export function HeaderActionsBar({ actions, Button, Dialog, ActionOverflowMenu, onError, collapseAfterPrimary = false, compact = false, preferredPrimaryId, PageHeader, overflowTestId = "kumiko-screen-projection-detail-actions-overflow", }) {
12
+ export function HeaderActionsBar({ actions, Button, Dialog, ActionOverflowMenu, onError, collapseAfterPrimary = false, compact = false, preferredPrimaryId, inlineActionIds, PageHeader, overflowTestId = "kumiko-screen-projection-detail-actions-overflow", }) {
13
13
  const t = useTranslation();
14
14
  const { toMenuItem, confirmDialog: pendingActionDialog } = useActionMenuItems({
15
15
  Dialog,
@@ -24,7 +24,6 @@ export function HeaderActionsBar({ actions, Button, Dialog, ActionOverflowMenu,
24
24
  if (actions.length <= maxPlainButtons || ActionOverflowMenu === undefined) {
25
25
  return (_jsx(_Fragment, { children: orderPlainHeaderActions(actions, preferredPrimaryId).map((action) => (_jsx(RenderEditActionButton, { action: action, Button: Button, Dialog: Dialog, hideIcon: true, onError: onError }, action.id))) }));
26
26
  }
27
- const primary = primaryHeaderAction(actions, preferredPrimaryId);
28
- const rest = actions.filter((a) => a.id !== primary?.id);
29
- return (_jsxs(_Fragment, { children: [primary !== undefined && (_jsx(RenderEditActionButton, { action: primary, Button: Button, Dialog: Dialog, hideIcon: true, onError: onError }, primary.id)), _jsx(ActionOverflowMenu, { label: t("kumiko.list.row-actions.more"), testId: overflowTestId, items: rest.map(toMenuItem) }), pendingActionDialog] }));
27
+ const { buttons, menu } = splitHeaderActions(actions, preferredPrimaryId, inlineActionIds);
28
+ return (_jsxs(_Fragment, { children: [buttons.map((action) => (_jsx(RenderEditActionButton, { action: action, Button: Button, Dialog: Dialog, hideIcon: true, onError: onError }, action.id))), menu.length > 0 && (_jsx(ActionOverflowMenu, { label: t("kumiko.list.row-actions.more"), testId: overflowTestId, items: menu.map(toMenuItem) })), pendingActionDialog] }));
30
29
  }
@@ -5,4 +5,9 @@ export type CompactHeaderPrimary = {
5
5
  readonly iconOnly: boolean;
6
6
  };
7
7
  export declare function compactHeaderPrimary(actions: readonly RenderEditAction[], preferredId?: string): CompactHeaderPrimary | undefined;
8
+ export type SplitHeaderActions = {
9
+ readonly buttons: readonly RenderEditAction[];
10
+ readonly menu: readonly RenderEditAction[];
11
+ };
12
+ export declare function splitHeaderActions(actions: readonly RenderEditAction[], preferredId?: string, inlineIds?: readonly string[]): SplitHeaderActions;
8
13
  export declare function orderPlainHeaderActions(actions: readonly RenderEditAction[], preferredId?: string): readonly RenderEditAction[];
@@ -25,6 +25,17 @@ export function compactHeaderPrimary(actions, preferredId) {
25
25
  const iconAction = primaryHeaderAction(actions);
26
26
  return canStandAsIconButton(iconAction) ? { action: iconAction, iconOnly: true } : undefined;
27
27
  }
28
+ // The primary action leads the visible buttons, the declared inline actions follow it,
29
+ // everything else goes into the menu.
30
+ export function splitHeaderActions(actions, preferredId, inlineIds) {
31
+ const primary = primaryHeaderAction(actions, preferredId);
32
+ const others = actions.filter((action) => action.id !== primary?.id);
33
+ const inline = others.filter((action) => inlineIds?.includes(action.id) === true);
34
+ return {
35
+ buttons: primary === undefined ? inline : [primary, ...inline],
36
+ menu: others.filter((action) => !inline.includes(action)),
37
+ };
38
+ }
28
39
  export function orderPlainHeaderActions(actions, preferredId) {
29
40
  const preferred = preferredId !== undefined ? actions.find((a) => a.id === preferredId) : undefined;
30
41
  return preferred === undefined ? actions : [preferred, ...actions.filter((a) => a !== preferred)];
@@ -55,7 +55,7 @@ export function KumikoScreen({ schema, qn, translate, entityId, onRowClick, onCo
55
55
  onCopyLink,
56
56
  });
57
57
  // Gate every entry (incl. direct URL): the body element is only mounted once the condition holds.
58
- const body = screen.visibleWhen !== undefined ? (_jsx(ScreenVisibilityGate, { featureName: schema.featureName, visibleWhen: screen.visibleWhen, fallback: screen.fallback, translate: translate, children: _jsx(StandInChainBoundary, { children: screenBody }) })) : (_jsx(StandInChainBoundary, { children: screenBody }));
58
+ const body = screen.visibleWhen !== undefined ? (_jsx(ScreenVisibilityGate, { entityId: entityId, featureName: schema.featureName, visibleWhen: screen.visibleWhen, fallback: screen.fallback, translate: translate, children: _jsx(StandInChainBoundary, { children: screenBody }) }, entityId)) : (_jsx(StandInChainBoundary, { children: screenBody }));
59
59
  // An embedded screen (e.g. a dashboard panel) keeps its parent's host and
60
60
  // must not portal its page header into the shell that hosts the outer screen.
61
61
  if (outerHost !== undefined) {
@@ -63,10 +63,16 @@ export function KumikoScreen({ schema, qn, translate, entityId, onRowClick, onCo
63
63
  }
64
64
  return _jsx(ReturnHostProvider, { value: ownHost, children: body });
65
65
  }
66
- function ScreenVisibilityGate({ featureName, visibleWhen, fallback, translate, children, }) {
67
- const result = useQuery(visibleWhen.query, {}, { live: true });
66
+ function ScreenVisibilityGate({ entityId, featureName, visibleWhen, fallback, translate, children, }) {
67
+ // An idParam gate without a route id has nothing to ask: treated as hidden, never queried.
68
+ const missingRouteId = visibleWhen.idParam !== undefined && entityId === undefined;
69
+ const result = useQuery(visibleWhen.query, visibleWhen.idParam !== undefined && entityId !== undefined
70
+ ? { [visibleWhen.idParam]: entityId }
71
+ : {}, { live: true, enabled: !missingRouteId });
68
72
  const wasShown = useRef(false);
69
- const verdict = evalVisibleWhenAfterShown(visibleWhen, result, wasShown.current);
73
+ const verdict = missingRouteId
74
+ ? "hidden"
75
+ : evalVisibleWhenAfterShown(visibleWhen, result, wasShown.current);
70
76
  useEffect(() => {
71
77
  if (verdict === "visible")
72
78
  wasShown.current = true;
@@ -77,7 +83,7 @@ function ScreenVisibilityGate({ featureName, visibleWhen, fallback, translate, c
77
83
  return _jsx(QueryLoadingBanner, {});
78
84
  }
79
85
  if (fallback !== undefined) {
80
- return _jsx(FallbackScreen, { featureName: featureName, fallback: fallback, translate: translate });
86
+ return (_jsx(FallbackScreen, { featureName: featureName, fallback: fallback, translate: translate, ...(visibleWhen.idParam !== undefined && entityId !== undefined && { entityId }) }));
81
87
  }
82
88
  return (_jsxs(_Fragment, { children: [_jsx(LockedHeaderScope, {}), _jsx(ScreenUnavailableBanner, {})] }));
83
89
  }
@@ -86,14 +92,14 @@ function ScreenUnavailableBanner() {
86
92
  const t = useTranslation();
87
93
  return (_jsx(Banner, { padded: true, variant: "info", testId: "kumiko-screen-unavailable", children: t("kumiko.screen.unavailable") }));
88
94
  }
89
- function FallbackScreen({ featureName, fallback, translate, }) {
95
+ function FallbackScreen({ featureName, fallback, translate, entityId, }) {
90
96
  const target = useEmbeddedScreen(featureName, fallback);
91
97
  // Also undefined when the user lacks access to the fallback screen, which is
92
98
  // not a framework error.
93
99
  if (target === undefined) {
94
100
  return (_jsxs(_Fragment, { children: [_jsx(LockedHeaderScope, {}), _jsx(ScreenUnavailableBanner, {})] }));
95
101
  }
96
- return (_jsx(LockedHeaderScope, { standInScreenId: lastSegment(target.qn), children: _jsx(KumikoScreen, { schema: target.schema, qn: target.qn, translate: translate }) }));
102
+ return (_jsx(LockedHeaderScope, { standInScreenId: lastSegment(target.qn), children: _jsx(KumikoScreen, { schema: target.schema, qn: target.qn, translate: translate, ...(entityId !== undefined && { entityId }) }) }));
97
103
  }
98
104
  function renderScreenBody({ schema, screen, translate, entityId, onRowClick, onCopyLink, }) {
99
105
  switch (screen.type) {
@@ -1,7 +1,8 @@
1
1
  import type { ProjectionDetailScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
2
- import type { Translate } from "../headless/index.ts";
3
- import { type StatusTone } from "../primitives.tsx";
4
- export declare function headerStatusTone(header: NonNullable<ProjectionDetailScreenDefinition["header"]>, record: Readonly<Record<string, unknown>>): StatusTone | undefined;
2
+ import { type Translate } from "../headless/index.ts";
3
+ import { type StatusBadgeOptionTone } from "../primitives.tsx";
4
+ export declare function headerStatusTone(header: NonNullable<ProjectionDetailScreenDefinition["header"]>, record: Readonly<Record<string, unknown>>): StatusBadgeOptionTone | undefined;
5
+ export declare function resolveHeaderStatusText(screen: ProjectionDetailScreenDefinition, record: Readonly<Record<string, unknown>>, translate: Translate): string;
5
6
  export declare function resolveSubtitleHref(header: ProjectionDetailScreenDefinition["header"], record: Readonly<Record<string, unknown>>): string | undefined;
6
7
  export type DetailTabItem = {
7
8
  readonly id: string;
@@ -1,5 +1,6 @@
1
- import { isWriteFormEditSection } from "@cosmicdrift/kumiko-framework/ui-types";
2
- import { statusToneForOptionTone, statusToneForValue } from "../primitives.js";
1
+ import { isFieldsEditSection, isFormatSpec, isWriteFormEditSection, normalizeEditField, sectionFieldSpecs, } from "@cosmicdrift/kumiko-framework/ui-types";
2
+ import { applyFormatSpec } from "../headless/index.js";
3
+ import { statusToneForOptionTone, statusToneForValue, } from "../primitives.js";
3
4
  import { isAbsoluteHttpUrl } from "./projection-detail-navigation.js";
4
5
  export function headerStatusTone(header, record) {
5
6
  // skip: a header without status has no tone
@@ -12,6 +13,36 @@ export function headerStatusTone(header, record) {
12
13
  const declaredTone = declared !== undefined ? statusToneForOptionTone(declared) : undefined;
13
14
  return declaredTone ?? statusToneForValue(value);
14
15
  }
16
+ function layoutStatusFormat(screen, statusField) {
17
+ for (const section of screen.layout.sections) {
18
+ if (!isFieldsEditSection(section))
19
+ continue;
20
+ for (const spec of sectionFieldSpecs(section)) {
21
+ const field = normalizeEditField(spec);
22
+ if (field.field !== statusField)
23
+ continue;
24
+ const renderer = field.renderer;
25
+ if (isFormatSpec(renderer) &&
26
+ (renderer.format === "enumOption" || renderer.format === "i18nText")) {
27
+ return renderer;
28
+ }
29
+ }
30
+ }
31
+ return undefined;
32
+ }
33
+ export function resolveHeaderStatusText(screen, record, translate) {
34
+ const statusField = screen.header?.status;
35
+ // skip: a header without status has no badge text
36
+ if (statusField === undefined)
37
+ return "";
38
+ const raw = record[statusField];
39
+ const rawText = String(raw ?? "");
40
+ const format = screen.header?.statusFormat ?? layoutStatusFormat(screen, statusField);
41
+ if (format === undefined)
42
+ return rawText;
43
+ const formatted = applyFormatSpec(format, raw, translate);
44
+ return formatted === "" ? rawText : formatted;
45
+ }
15
46
  export function resolveSubtitleHref(header, record) {
16
47
  // skip: no subtitleHref declared
17
48
  if (header?.subtitleHref === undefined)
@@ -47,6 +47,8 @@ function renderPageHeaderBranch(model, pieces, headerSlot, PageHeader) {
47
47
  return (_jsxs(_Fragment, { children: [_jsx(PageHeader, { ...(header !== undefined && { title: String(record[header.title] ?? "") }), ...(header === undefined && recordTitle !== undefined && { title: recordTitle }), ...(!pageHeaderCompact && statusBadge !== undefined && { status: statusBadge }), ...(hasHeaderActions && {
48
48
  actions: (_jsx(Grid, { columns: "auto", testId: "kumiko-screen-projection-detail-actions", children: _jsx(HeaderActionsBar, { actions: model.headerActionsList, Button: Button, Dialog: Dialog, ActionOverflowMenu: ActionOverflowMenu, onError: setActionError, collapseAfterPrimary: true, compact: pageHeaderCompact, PageHeader: PageHeader, ...(screen.primaryAction !== undefined && {
49
49
  preferredPrimaryId: screen.primaryAction,
50
+ }), ...(screen.inlineActions !== undefined && {
51
+ inlineActionIds: screen.inlineActions,
50
52
  }) }) })),
51
53
  }) }), MetricBand === undefined && bandSubtitle, MetricBand === undefined && headerSlot, (hasMetrics || bandSubtitle !== undefined || headerSlot !== undefined) &&
52
54
  (MetricBand !== undefined || hasMetrics) &&
@@ -2,13 +2,13 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
2
2
  import { Fragment } from "react";
3
3
  import { ErrorReference } from "./error-reference.js";
4
4
  import { HeaderActionsBar } from "./header-actions-bar.js";
5
- import { headerStatusTone, resolveSubtitleHref } from "./projection-detail-header-model.js";
5
+ import { headerStatusTone, resolveHeaderStatusText, resolveSubtitleHref, } from "./projection-detail-header-model.js";
6
6
  import { buildSubtitleParts, runMetricNavigate } from "./projection-detail-navigation.js";
7
7
  import { navigateTargetAllows } from "./screen-access.js";
8
8
  export function renderHeaderActionsContent(model) {
9
9
  const { screen, primitives, headerActionsList, setActionError } = model;
10
10
  const { Grid, Button, Dialog, ActionOverflowMenu } = primitives;
11
- return (_jsx(Grid, { columns: "auto", testId: "kumiko-screen-projection-detail-actions", children: _jsx(HeaderActionsBar, { actions: headerActionsList, Button: Button, Dialog: Dialog, ActionOverflowMenu: ActionOverflowMenu, onError: setActionError, ...(screen.primaryAction !== undefined && { preferredPrimaryId: screen.primaryAction }) }) }));
11
+ return (_jsx(Grid, { columns: "auto", testId: "kumiko-screen-projection-detail-actions", children: _jsx(HeaderActionsBar, { actions: headerActionsList, Button: Button, Dialog: Dialog, ActionOverflowMenu: ActionOverflowMenu, onError: setActionError, ...(screen.primaryAction !== undefined && { preferredPrimaryId: screen.primaryAction }), ...(screen.inlineActions !== undefined && { inlineActionIds: screen.inlineActions }) }) }));
12
12
  }
13
13
  // Plain Text, not Heading — the Card title slot already renders as its own
14
14
  // heading element (h3); nesting another heading (h1) inside it is invalid HTML.
@@ -16,7 +16,8 @@ export function renderHeaderTitleSlot(model) {
16
16
  const { screen, record, primitives } = model;
17
17
  const { Grid, Text, StatusBadge } = primitives;
18
18
  const header = screen.header;
19
- return header !== undefined ? (header.status !== undefined ? (_jsxs(Grid, { columns: "auto", children: [_jsx(Text, { testId: "kumiko-screen-projection-detail-title", children: String(record[header.title] ?? "") }), StatusBadge !== undefined ? (_jsx(StatusBadge, { value: String(record[header.status] ?? ""), tone: headerStatusTone(header, record), testId: "kumiko-screen-projection-detail-status" })) : (_jsx(Text, { testId: "kumiko-screen-projection-detail-status", children: String(record[header.status] ?? "") }))] })) : (_jsx(Text, { testId: "kumiko-screen-projection-detail-title", children: String(record[header.title] ?? "") }))) : undefined;
19
+ const statusText = resolveHeaderStatusText(screen, record, model.effectiveTranslate);
20
+ return header !== undefined ? (header.status !== undefined ? (_jsxs(Grid, { columns: "auto", children: [_jsx(Text, { testId: "kumiko-screen-projection-detail-title", children: String(record[header.title] ?? "") }), StatusBadge !== undefined ? (_jsx(StatusBadge, { value: statusText, tone: headerStatusTone(header, record), testId: "kumiko-screen-projection-detail-status" })) : (_jsx(Text, { testId: "kumiko-screen-projection-detail-status", children: statusText }))] })) : (_jsx(Text, { testId: "kumiko-screen-projection-detail-title", children: String(record[header.title] ?? "") }))) : undefined;
20
21
  }
21
22
  function renderSubtitleParts(model, subtitleParts) {
22
23
  const { Text, Link } = model.primitives;
@@ -75,7 +76,7 @@ export function renderStatusBadge(model) {
75
76
  const { screen, record, primitives } = model;
76
77
  const { StatusBadge } = primitives;
77
78
  const header = screen.header;
78
- return header?.status !== undefined && StatusBadge !== undefined ? (_jsx(StatusBadge, { value: String(record[header.status] ?? ""), tone: headerStatusTone(header, record), testId: "kumiko-screen-projection-detail-status" })) : undefined;
79
+ return header?.status !== undefined && StatusBadge !== undefined ? (_jsx(StatusBadge, { value: resolveHeaderStatusText(screen, record, model.effectiveTranslate), tone: headerStatusTone(header, record), testId: "kumiko-screen-projection-detail-status" })) : undefined;
79
80
  }
80
81
  // Lives in the band's subtitle row so it shares the metrics' horizontal inset.
81
82
  export function renderBandSubtitle(model, statusBadge, headerSubtitleSlot, pageHeaderCompact) {
@@ -43,7 +43,7 @@ export declare function buildProjectionRowActions(options: {
43
43
  }): readonly DataTableRowAction[] | undefined;
44
44
  type OpenToolbarDrawer = (action: ToolbarAction & {
45
45
  readonly kind: "drawer";
46
- }) => void;
46
+ }, initialValues?: Readonly<Record<string, unknown>>) => void;
47
47
  export declare function buildProjectionToolbarActions(options: {
48
48
  readonly toolbarActions: readonly RelatedListToolbarAction[] | undefined;
49
49
  readonly translate: Translate;
@@ -277,7 +277,7 @@ function buildNavigateToolbarAction(action, translate, nav, host, prefill, recor
277
277
  },
278
278
  };
279
279
  }
280
- function buildDrawerToolbarAction(action, translate, openDrawer) {
280
+ function buildDrawerToolbarAction(action, translate, openDrawer, record) {
281
281
  const actionIcon = resolveActionIcon(action.id);
282
282
  return {
283
283
  id: action.id,
@@ -285,10 +285,12 @@ function buildDrawerToolbarAction(action, translate, openDrawer) {
285
285
  ...(action.style !== undefined && { style: action.style }),
286
286
  confirmRequired: false,
287
287
  ...(actionIcon !== undefined && { icon: actionIcon }),
288
- onTrigger: () => openDrawer(action),
288
+ onTrigger: () => action.params !== undefined && record !== undefined
289
+ ? openDrawer(action, evalRowExtractor(action.params, record))
290
+ : openDrawer(action),
289
291
  };
290
292
  }
291
- function buildWriteHandlerToolbarAction(action, translate, refetch, dispatcher) {
293
+ function buildWriteHandlerToolbarAction(action, translate, refetch, dispatcher, record) {
292
294
  const actionIcon = resolveActionIcon(action.id);
293
295
  return {
294
296
  id: action.id,
@@ -300,7 +302,10 @@ function buildWriteHandlerToolbarAction(action, translate, refetch, dispatcher)
300
302
  confirmLabel: translate(action.confirmLabel),
301
303
  }),
302
304
  onTrigger: async () => {
303
- const result = await dispatcher.write(action.handler, action.payload ?? {});
305
+ const extracted = action.params !== undefined && record !== undefined
306
+ ? evalRowExtractor(action.params, record)
307
+ : undefined;
308
+ const result = await dispatcher.write(action.handler, { ...action.payload, ...extracted });
304
309
  if (!result.isSuccess) {
305
310
  throw new WriteFailedError(result.error, dispatcherErrorText(result.error, translate));
306
311
  }
@@ -308,6 +313,15 @@ function buildWriteHandlerToolbarAction(action, translate, refetch, dispatcher)
308
313
  },
309
314
  };
310
315
  }
316
+ // Fail closed: `visible` and `params` are evaluated against the parent record, so without one
317
+ // the action stays hidden. (navigate falls back to the implicit parent prefill without params.)
318
+ function isToolbarActionHiddenForRecord(action, record) {
319
+ if (action.visible !== undefined &&
320
+ (record === undefined || !evalFieldCondition(action.visible, record))) {
321
+ return true;
322
+ }
323
+ return action.kind !== "navigate" && action.params !== undefined && record === undefined;
324
+ }
311
325
  // Shared by entityList, projectionList, and relatedList toolbars so the
312
326
  // three call sites can't drift apart.
313
327
  export function buildProjectionToolbarActions(options) {
@@ -316,11 +330,8 @@ export function buildProjectionToolbarActions(options) {
316
330
  return undefined;
317
331
  const out = [];
318
332
  for (const action of toolbarActions) {
319
- // Fail closed: a conditional action without a record to evaluate against stays hidden.
320
- if (action.visible !== undefined &&
321
- (record === undefined || !evalFieldCondition(action.visible, record))) {
333
+ if (isToolbarActionHiddenForRecord(action, record))
322
334
  continue;
323
- }
324
335
  if (action.kind === "navigate") {
325
336
  if (access !== undefined &&
326
337
  !navigateTargetAllows({ screen: action.screen }, access.appFeatures, access.userRoles)) {
@@ -334,13 +345,13 @@ export function buildProjectionToolbarActions(options) {
334
345
  warnDrawerActionDropped("toolbarAction", action.id);
335
346
  continue;
336
347
  }
337
- out.push(buildDrawerToolbarAction(action, translate, openDrawer));
348
+ out.push(buildDrawerToolbarAction(action, translate, openDrawer, record));
338
349
  continue;
339
350
  }
340
351
  // writeHandler — skip without a dispatcher instead of crashing (same as rowActions).
341
352
  if (dispatcher === undefined)
342
353
  continue;
343
- out.push(buildWriteHandlerToolbarAction(action, translate, refetch, dispatcher));
354
+ out.push(buildWriteHandlerToolbarAction(action, translate, refetch, dispatcher, record));
344
355
  }
345
356
  return out.length > 0 ? out : undefined;
346
357
  }
@@ -68,5 +68,12 @@ export declare function navigateAfterRedirect(options: {
68
68
  readonly resultData: unknown;
69
69
  readonly record: Readonly<Record<string, unknown>>;
70
70
  }): void;
71
+ export declare function navigateAfterDrawerSubmit(options: {
72
+ readonly nav: NavApi;
73
+ readonly schema: FeatureSchema;
74
+ readonly appFeatures: readonly FeatureSchema[];
75
+ readonly redirect: string | ActionFormRedirect | undefined;
76
+ readonly resultData: unknown;
77
+ }): void;
71
78
  export declare function overlayUpdatedProjection(record: Readonly<Record<string, unknown>>, resultData: unknown): Readonly<Record<string, unknown>>;
72
79
  export {};
@@ -320,6 +320,18 @@ export function navigateAfterRedirect(options) {
320
320
  }
321
321
  navigateToRedirectTarget(nav, resolveRedirectTarget(redirect, resultData, schema, appFeatures, record));
322
322
  }
323
+ // A drawer is no navigation target, so returnTo does not apply. Redirecting onto the host
324
+ // screen would only reset its filters/scroll/tab; the drawer's close + refetch already refreshed it.
325
+ export function navigateAfterDrawerSubmit(options) {
326
+ const { nav, schema, appFeatures, redirect, resultData } = options;
327
+ if (redirect === undefined)
328
+ return;
329
+ const target = resolveRedirectTarget(redirect, resultData, schema, appFeatures);
330
+ const hostScreenId = nav.route === undefined ? undefined : lastSegment(nav.route.screenId);
331
+ const isHostScreen = target.screenId === hostScreenId && target.entityId === undefined && target.tab === undefined;
332
+ if (!isHostScreen)
333
+ navigateToRedirectTarget(nav, target);
334
+ }
323
335
  // The update payload nests the saved projection under `data`; a changed
324
336
  // parent FK lives there, not in the pre-edit `record`.
325
337
  export function overlayUpdatedProjection(record, resultData) {
@@ -92,6 +92,11 @@ function buildRowTone(rule) {
92
92
  return undefined;
93
93
  return (row) => (evalFieldCondition(rule, { ...row.values }) ? rule.tone : undefined);
94
94
  }
95
+ function pickEmptyStateText(emptyState, record) {
96
+ if (emptyState === undefined)
97
+ return undefined;
98
+ return (emptyState.variants?.find((variant) => evalFieldCondition(variant.when, record)) ?? emptyState);
99
+ }
95
100
  export function RelatedListSection({ section, parentId, record, featureName, translate, hideTitle, grow, onOpenDrawer, actions, embedded, onAfterWrite, refreshToken, }) {
96
101
  const { Banner, Section, Card, FillContainer, Text, Button, Dialog } = usePrimitives();
97
102
  const [emptyStateActionError, setEmptyStateActionError] = useState(null);
@@ -248,7 +253,7 @@ export function RelatedListSection({ section, parentId, record, featureName, tra
248
253
  host,
249
254
  access: { appFeatures, userRoles },
250
255
  ...(onOpenDrawer !== undefined && {
251
- openDrawer: (action) => onOpenDrawer(action, undefined),
256
+ openDrawer: onOpenDrawer,
252
257
  }),
253
258
  }), [
254
259
  appFeatures,
@@ -302,7 +307,8 @@ export function RelatedListSection({ section, parentId, record, featureName, tra
302
307
  onOpenDrawer,
303
308
  refetchSelfAndHost,
304
309
  ]);
305
- const emptyStateContent = section.emptyState !== undefined ? (_jsxs(_Fragment, { children: [_jsx(Text, { children: section.emptyState.title }), section.emptyState.description !== undefined && (_jsx(Text, { variant: "small", children: section.emptyState.description })), emptyStateActionButton !== undefined && (_jsx(RenderEditActionButton, { action: emptyStateActionButton, Button: Button, Dialog: Dialog, onError: setEmptyStateActionError }))] })) : undefined;
310
+ const emptyStateText = pickEmptyStateText(section.emptyState, record);
311
+ const emptyStateContent = emptyStateText !== undefined ? (_jsxs(_Fragment, { children: [_jsx(Text, { children: emptyStateText.title }), emptyStateText.description !== undefined && (_jsx(Text, { variant: "small", children: emptyStateText.description })), emptyStateActionButton !== undefined && (_jsx(RenderEditActionButton, { action: emptyStateActionButton, Button: Button, Dialog: Dialog, onError: setEmptyStateActionError }))] })) : undefined;
306
312
  // Without a cursor-driven pager the client-side sort covers only the fetched
307
313
  // rows; the banner keeps it from claiming "these are the top N".
308
314
  const content = listRows.firstLoad ? (_jsx(Banner, { padded: true, variant: "loading", testId: "related-list-loading", children: t("kumiko.widget.loading") })) : listRows.error ? (_jsx(Banner, { padded: true, variant: "error", testId: "related-list-error", children: dispatcherErrorText(listRows.error, effectiveTranslate) })) : (_jsxs(_Fragment, { children: [listRows.truncated && (_jsx(Banner, { variant: "info", testId: "related-list-truncated", children: effectiveTranslate("kumiko.list.related-list-truncated", {
@@ -91,6 +91,15 @@ export function computeRelatedListSectionViewModel(sectionSpec, translate) {
91
91
  ...(sectionSpec.emptyState.action !== undefined && {
92
92
  action: sectionSpec.emptyState.action,
93
93
  }),
94
+ ...(sectionSpec.emptyState.variants !== undefined && {
95
+ variants: sectionSpec.emptyState.variants.map((variant) => ({
96
+ when: variant.when,
97
+ title: translate(variant.title),
98
+ ...(variant.description !== undefined && {
99
+ description: translate(variant.description),
100
+ }),
101
+ })),
102
+ }),
94
103
  },
95
104
  }),
96
105
  };
@@ -1,4 +1,4 @@
1
- import type { EditFieldSpec, EditSectionSpec, FieldAffix, FieldFillAction, FieldIconKey, FieldRenderer, IconKey, ListBulkAction, ListColumnSpec, ListFacetSpec, ListSortSpec, ListTableOptions, OptionsQueryPayload, PlatformComponent, RelatedListRowTone, RelatedListToolbarAction, RowAction, RowActionMode, ScreenSlots, SelectOptionTone } from "@cosmicdrift/kumiko-framework/ui-types";
1
+ import type { EditFieldSpec, EditSectionSpec, FieldAffix, FieldFillAction, FieldIconKey, FieldRenderer, IconKey, ListBulkAction, ListColumnSpec, ListFacetSpec, ListSortSpec, ListTableOptions, OptionsQueryPayload, PlatformComponent, RelatedListEmptyStateVariant, RelatedListRowTone, RelatedListToolbarAction, RowAction, RowActionMode, ScreenSlots, SelectOptionTone } from "@cosmicdrift/kumiko-framework/ui-types";
2
2
  export type RuntimeRenderer = (value: unknown, row?: Readonly<Record<string, unknown>>) => string;
3
3
  /** Where a cell's text links to. `href` comes from NavApi.hrefFor (new-tab and
4
4
  * copy-link work); `onPress` is the in-app navigation for a plain click. */
@@ -369,6 +369,7 @@ export type EditRelatedListSectionViewModel = {
369
369
  readonly title: string;
370
370
  readonly description?: string;
371
371
  readonly action?: RowAction;
372
+ readonly variants?: readonly RelatedListEmptyStateVariant[];
372
373
  };
373
374
  };
374
375
  export type EditWriteFormSectionViewModel = {
@@ -0,0 +1,6 @@
1
+ import type { FeatureSchema } from "../app/feature-schema.ts";
2
+ import type { LiveEvent, LiveEventSubscriber } from "../sse/live-events.tsx";
3
+ export declare function entityFromQueryType(type: string): string | undefined;
4
+ export declare function liveEntitiesForQuery(type: string, features: readonly FeatureSchema[]): readonly string[];
5
+ export declare function useLiveEntitiesForQuery(type: string): readonly string[];
6
+ export declare function subscribeToEntities(subscribe: LiveEventSubscriber, entities: readonly string[], listener: (event: LiveEvent) => void): () => void;
@@ -0,0 +1,44 @@
1
+ import { useMemo } from "react";
2
+ import { useAppFeatures } from "../app/app-features-context.js";
3
+ import { toKebab } from "../app/qn.js";
4
+ // Entity of a standard `<feature>:query:<entity>:<verb>` type; undefined for
5
+ // non-conforming types so live mode skips them instead of subscribing to a
6
+ // channel no event will ever match.
7
+ export function entityFromQueryType(type) {
8
+ const parts = type.split(":");
9
+ if (parts.length !== 4)
10
+ return undefined;
11
+ if (parts[1] !== "query")
12
+ return undefined;
13
+ return parts[2];
14
+ }
15
+ // The server signals both the r.entity() name and its kebab-case form (executors
16
+ // may write under either), so the client listens on both.
17
+ export function liveEntitiesForQuery(type, features) {
18
+ const names = new Set();
19
+ const nameSegment = entityFromQueryType(type);
20
+ if (nameSegment !== undefined)
21
+ names.add(nameSegment);
22
+ for (const feature of features) {
23
+ for (const entity of feature.queryLiveEntities?.[type] ?? [])
24
+ names.add(entity);
25
+ }
26
+ const subscribed = new Set();
27
+ for (const name of names) {
28
+ subscribed.add(name);
29
+ subscribed.add(toKebab(name));
30
+ }
31
+ return [...subscribed].sort();
32
+ }
33
+ export function useLiveEntitiesForQuery(type) {
34
+ const features = useAppFeatures();
35
+ const featuresKey = useMemo(() => liveEntitiesForQuery(type, features).join("\n"), [type, features]);
36
+ return useMemo(() => (featuresKey === "" ? [] : featuresKey.split("\n")), [featuresKey]);
37
+ }
38
+ export function subscribeToEntities(subscribe, entities, listener) {
39
+ const unsubscribers = entities.map((entity) => subscribe(entity, listener));
40
+ return () => {
41
+ for (const unsubscribe of unsubscribers)
42
+ unsubscribe();
43
+ };
44
+ }
@@ -1,4 +1,5 @@
1
1
  import type { DispatcherError } from "../headless/index.ts";
2
+ export { entityFromQueryType } from "./live-query-entities.ts";
2
3
  export type UseQueryResult<TData> = {
3
4
  readonly data: TData | null;
4
5
  readonly error: DispatcherError | null;
@@ -11,5 +12,4 @@ export type UseQueryOptions = {
11
12
  readonly refetchIntervalMs?: number;
12
13
  readonly concurrency?: number;
13
14
  };
14
- export declare function entityFromQueryType(type: string): string | undefined;
15
15
  export declare function useQuery<TData = unknown>(type: string, payload: unknown, options?: UseQueryOptions): UseQueryResult<TData>;
@@ -1,19 +1,9 @@
1
1
  import { useCallback, useEffect, useRef, useState } from "react";
2
2
  import { useDispatcher, useOptionalQueryPools } from "../context/dispatcher-context.js";
3
3
  import { useLiveEvents } from "../sse/live-events.js";
4
+ import { subscribeToEntities, useLiveEntitiesForQuery } from "./live-query-entities.js";
4
5
  import { createLiveRefetchScheduler } from "./live-refetch-scheduler.js";
5
- // Extract the entity-name from a standard Kumiko query type. Returns
6
- // undefined for non-conforming types so the live-mode silently skips
7
- // them instead of subscribing to a channel no event will ever match.
8
- export function entityFromQueryType(type) {
9
- // Expected shape: "<feature>:query:<entity>:<verb>"
10
- const parts = type.split(":");
11
- if (parts.length !== 4)
12
- return undefined;
13
- if (parts[1] !== "query")
14
- return undefined;
15
- return parts[2];
16
- }
6
+ export { entityFromQueryType } from "./live-query-entities.js";
17
7
  function isValidConcurrency(value) {
18
8
  return value !== undefined && Number.isFinite(value) && value >= 1;
19
9
  }
@@ -118,20 +108,20 @@ export function useQuery(type, payload, options = {}) {
118
108
  // A separate effect ensures changes to `live` or `type` update the
119
109
  // subscription lifecycle exactly once.
120
110
  const subscribeLive = useLiveEvents();
111
+ const liveEntities = useLiveEntitiesForQuery(type);
121
112
  useEffect(() => {
122
113
  // skip: live mode or query disabled, no SSE subscription needed
123
114
  if (!live || !enabled)
124
115
  return;
125
- const entity = entityFromQueryType(type);
126
- // skip: query type has no mapped entity, nothing to subscribe to
127
- if (entity === undefined)
116
+ // skip: no entity to listen on, nothing to subscribe to
117
+ if (liveEntities.length === 0)
128
118
  return;
129
119
  const scheduler = createLiveRefetchScheduler(() => runFetch(true));
130
- const unsubscribe = subscribeLive(entity, scheduler.trigger);
120
+ const unsubscribe = subscribeToEntities(subscribeLive, liveEntities, scheduler.trigger);
131
121
  return () => {
132
122
  unsubscribe();
133
123
  scheduler.dispose();
134
124
  };
135
- }, [live, enabled, type, runFetch, subscribeLive]);
125
+ }, [live, enabled, liveEntities, runFetch, subscribeLive]);
136
126
  return { data, error, loading, refetch: run };
137
127
  }
@@ -1334,13 +1334,15 @@ export type TabsProps = {
1334
1334
  readonly testId?: string;
1335
1335
  };
1336
1336
  export type StatusTone = "ok" | "warn" | "bad" | "critical" | "muted";
1337
+ /** Tones a select option can request: the status tones plus the brand `accent`. */
1338
+ export type StatusBadgeOptionTone = StatusTone | "accent";
1337
1339
  /** Status pill. `value` is the raw column value — tone-mapping is the app's job. */
1338
1340
  export type StatusBadgeProps = {
1339
1341
  readonly value: string;
1340
- readonly tone?: StatusTone;
1342
+ readonly tone?: StatusBadgeOptionTone;
1341
1343
  readonly testId?: string;
1342
1344
  };
1343
- export declare function statusToneForOptionTone(tone: SelectOptionTone): StatusTone | undefined;
1345
+ export declare function statusToneForOptionTone(tone: SelectOptionTone): StatusBadgeOptionTone | undefined;
1344
1346
  export declare function statusToneForValue(value: string): StatusTone | undefined;
1345
1347
  /** Structured JSON display (audit payload/metadata, job logs, unsupported
1346
1348
  * jsonb/embedded field fallback). Takes the raw value, not a pre-stringified
@@ -50,6 +50,8 @@ const STATUS_TONE_BY_OPTION_TONE = {
50
50
  ok: "ok",
51
51
  warn: "warn",
52
52
  bad: "bad",
53
+ critical: "critical",
54
+ accent: "accent",
53
55
  neutral: "muted",
54
56
  };
55
57
  // Own-key check: an unknown tone from an untyped definition must yield undefined so callers fall back to the value heuristic.
@@ -25,6 +25,7 @@ function PanelBody({ panel, label, screenId, screenParams, translate, }) {
25
25
  ...screenParams.filterParams,
26
26
  ...screenParams.rangeParams,
27
27
  ...screenParams.recordParams,
28
+ ...panel.params,
28
29
  } }));
29
30
  }
30
31
  const text = label ?? "";
@@ -1,4 +1,5 @@
1
1
  import type { ScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
2
+ import type { NavNode } from "../../headless/index.ts";
2
3
  export type BreadcrumbCrumb = {
3
4
  readonly label: string;
4
5
  readonly screenId?: string;
@@ -9,3 +10,4 @@ export declare function screenTitleKey(screenShortId: string): string;
9
10
  * (unknown screen, or no explicit/heuristic parent). */
10
11
  export declare function resolveParentScreenId(screens: readonly ScreenDefinition[], screenId: string): string | undefined;
11
12
  export declare function resolveDetailBreadcrumb(screens: readonly ScreenDefinition[], detailScreenId: string, t: (key: string) => string): readonly BreadcrumbCrumb[] | undefined;
13
+ export declare function activeNavLabel(nodes: readonly NavNode[], screenId: string, t: (key: string) => string): string | undefined;
@@ -32,3 +32,14 @@ export function resolveDetailBreadcrumb(screens, detailScreenId, t) {
32
32
  { label: t(screenTitleKey(detailScreenId)) },
33
33
  ];
34
34
  }
35
+ export function activeNavLabel(nodes, screenId, t) {
36
+ for (const node of nodes) {
37
+ if (node.screen !== undefined && lastSegment(node.screen) === screenId) {
38
+ return node.label.includes(".") || node.label.includes(":") ? t(node.label) : node.label;
39
+ }
40
+ const child = activeNavLabel(node.children, screenId, t);
41
+ if (child !== undefined)
42
+ return child;
43
+ }
44
+ return undefined;
45
+ }
@@ -4,9 +4,10 @@ type ShellHeaderUser = {
4
4
  readonly id: string;
5
5
  readonly roles: readonly string[];
6
6
  };
7
- export declare function ShellHeader({ schema, user, headerActions, }: {
7
+ export declare function ShellHeader({ schema, user, headerActions, allowedNavQns, }: {
8
8
  readonly schema: AppSchema | FeatureSchema;
9
9
  readonly user?: ShellHeaderUser;
10
10
  readonly headerActions?: ReactNode;
11
+ readonly allowedNavQns?: ReadonlySet<string>;
11
12
  }): ReactNode;
12
13
  export {};
@@ -16,11 +16,11 @@ import { cn } from "../lib/cn.js";
16
16
  import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator, } from "../ui/breadcrumb.js";
17
17
  import { SidebarTrigger } from "../ui/sidebar.js";
18
18
  import { HeaderOverflowMenuContext, headerOverflowMenuItemClass } from "./header-overflow-menu.js";
19
- import { buildNavRegistrySliceForApp, lastSegment } from "./nav-tree.js";
19
+ import { buildNavRegistrySliceForApp } from "./nav-tree.js";
20
20
  import { usePageHeaderSlot } from "./page-header-slot.js";
21
- import { resolveDetailBreadcrumb, screenTitleKey, } from "./shell-breadcrumb.js";
21
+ import { activeNavLabel, resolveDetailBreadcrumb, screenTitleKey, } from "./shell-breadcrumb.js";
22
22
  import { SHELL_HEADER_CONTROLS_CLASSES } from "./uniform-bar-controls.js";
23
- export function ShellHeader({ schema, user, headerActions, }) {
23
+ export function ShellHeader({ schema, user, headerActions, allowedNavQns, }) {
24
24
  const nav = useNav();
25
25
  const t = useTranslation();
26
26
  const appSchema = toAppSchema(schema);
@@ -28,6 +28,12 @@ export function ShellHeader({ schema, user, headerActions, }) {
28
28
  const source = buildNavRegistrySliceForApp(appSchema);
29
29
  return resolveNavigation({ source, ...(user !== undefined && { user }) });
30
30
  }, [appSchema, user]);
31
+ const allowedTree = useMemo(() => {
32
+ if (allowedNavQns === undefined)
33
+ return undefined;
34
+ const source = buildNavRegistrySliceForApp(appSchema, allowedNavQns);
35
+ return resolveNavigation({ source, ...(user !== undefined && { user }) });
36
+ }, [appSchema, user, allowedNavQns]);
31
37
  const allScreens = useMemo(() => appSchema.features.flatMap((f) => f.screens), [appSchema.features]);
32
38
  const slot = usePageHeaderSlot();
33
39
  const standInScreenId = slot?.breadcrumbScreenId;
@@ -43,11 +49,12 @@ export function ShellHeader({ schema, user, headerActions, }) {
43
49
  if (detailCrumbs !== undefined && detailCrumbs.length > 1)
44
50
  return detailCrumbs;
45
51
  }
46
- const navLabel = activeNavLabel(tree, screenId, (k) => t(k));
52
+ const navLabel = (allowedTree !== undefined ? activeNavLabel(allowedTree, screenId, t) : undefined) ??
53
+ activeNavLabel(tree, screenId, t);
47
54
  if (navLabel !== undefined)
48
55
  return [{ label: navLabel }];
49
56
  return resolveDetailBreadcrumb(allScreens, screenId, t);
50
- }, [allScreens, screenId, entityId, t, tree]);
57
+ }, [allScreens, screenId, entityId, t, tree, allowedTree]);
51
58
  const setStatusElement = slot?.setStatusElement;
52
59
  const setActionsElement = slot?.setActionsElement;
53
60
  const titleOverride = slot?.title;
@@ -147,14 +154,3 @@ function HeaderOverflow({ items, headerActions, }) {
147
154
  // being restyled through a child selector that reaches into their markup.
148
155
  className: "flex flex-col items-stretch gap-1", onClick: () => setOpen(false), children: _jsx(HeaderOverflowMenuContext.Provider, { value: true, children: headerActions }) }))] })] }));
149
156
  }
150
- function activeNavLabel(nodes, screenId, t) {
151
- for (const node of nodes) {
152
- if (node.screen !== undefined && lastSegment(node.screen) === screenId) {
153
- return node.label.includes(".") || node.label.includes(":") ? t(node.label) : node.label;
154
- }
155
- const child = activeNavLabel(node.children, screenId, t);
156
- if (child !== undefined)
157
- return child;
158
- }
159
- return undefined;
160
- }
@@ -150,7 +150,7 @@ export function WorkspaceShell({ brand, schema, topbarActions, user, initialWork
150
150
  // The second column appears only when a screen fills it (sidebar-09 pattern);
151
151
  // otherwise every other screen would have an empty strip beside the navigation.
152
152
  const panel = useSidebarPanelHost();
153
- return (_jsx(SidebarProvider, { ...fillCls.provider, children: _jsxs(SidebarPanelProvider, { value: panel.value, children: [_jsxs(Sidebar, { collapsible: "icon", mobileTitle: t("kumiko.sidebar.title"), mobileDescription: t("kumiko.sidebar.description"), children: [_jsxs(SidebarHeader, { "data-kumiko-layout": "sidebar-header", children: [brand, switcher] }), _jsx(SidebarContent, { "data-kumiko-layout": "sidebar-nav", children: sidebarContent }), sidebarFooter !== undefined && (_jsx(SidebarFooter, { "data-kumiko-layout": "sidebar-footer", className: "border-t border-sidebar-border", children: sidebarFooter })), _jsx(SidebarRail, { "aria-label": t("kumiko.sidebar.toggle"), title: t("kumiko.sidebar.toggle") })] }), _jsx("div", { ref: panel.ref, "data-kumiko-layout": "sidebar-panel", className: panel.occupied ? "flex h-svh shrink-0 flex-col" : "hidden" }), _jsx(SidebarInset, { className: fillCls.inset, children: _jsxs(PageHeaderSlotProvider, { children: [_jsx(ShellHeader, { schema: app, ...(user !== undefined && { user }), ...(topbarActions !== undefined && { headerActions: topbarActions }) }), _jsx("main", { className: fillCls.main, children: activeTarget !== undefined ? (_jsx(EditorPanel, { resolvers: resolvers })) : (_jsx(UserRolesProvider, { roles: user?.roles, children: children })) })] }) })] }) }));
153
+ return (_jsx(SidebarProvider, { ...fillCls.provider, children: _jsxs(SidebarPanelProvider, { value: panel.value, children: [_jsxs(Sidebar, { collapsible: "icon", mobileTitle: t("kumiko.sidebar.title"), mobileDescription: t("kumiko.sidebar.description"), children: [_jsxs(SidebarHeader, { "data-kumiko-layout": "sidebar-header", children: [brand, switcher] }), _jsx(SidebarContent, { "data-kumiko-layout": "sidebar-nav", children: sidebarContent }), sidebarFooter !== undefined && (_jsx(SidebarFooter, { "data-kumiko-layout": "sidebar-footer", className: "border-t border-sidebar-border", children: sidebarFooter })), _jsx(SidebarRail, { "aria-label": t("kumiko.sidebar.toggle"), title: t("kumiko.sidebar.toggle") })] }), _jsx("div", { ref: panel.ref, "data-kumiko-layout": "sidebar-panel", className: panel.occupied ? "flex h-svh shrink-0 flex-col" : "hidden" }), _jsx(SidebarInset, { className: fillCls.inset, children: _jsxs(PageHeaderSlotProvider, { children: [_jsx(ShellHeader, { schema: app, ...(user !== undefined && { user }), ...(allowedNavQns !== undefined && { allowedNavQns }), ...(topbarActions !== undefined && { headerActions: topbarActions }) }), _jsx("main", { className: fillCls.main, children: activeTarget !== undefined ? (_jsx(EditorPanel, { resolvers: resolvers })) : (_jsx(UserRolesProvider, { roles: user?.roles, children: children })) })] }) })] }) }));
154
154
  }
155
155
  // --- helpers (exported for tests) ---
156
156
  export function filterByAccess(workspaces, userRoles) {
@@ -2,6 +2,6 @@ import type { ListColumnViewModel } from "../../../headless/index.ts";
2
2
  /** Optional presentation props every DataTableCell gets from its column. */
3
3
  export declare function columnCellPresentationProps(col: ListColumnViewModel): {
4
4
  optionLabels?: Readonly<Record<string, string>> | undefined;
5
- optionTones?: Readonly<Partial<Record<string, "bad" | "neutral" | "ok" | "warn">>> | undefined;
5
+ optionTones?: Readonly<Partial<Record<string, "accent" | "bad" | "critical" | "neutral" | "ok" | "warn">>> | undefined;
6
6
  grouping?: boolean | undefined;
7
7
  };
@@ -259,6 +259,8 @@ const ROW_TONE_CLASS = {
259
259
  bad: "bg-status-bad/10 hover:bg-status-bad/15",
260
260
  warn: "bg-status-warn/10 hover:bg-status-warn/15",
261
261
  ok: "bg-status-ok/10 hover:bg-status-ok/15",
262
+ critical: "bg-status-critical/10 hover:bg-status-critical/15",
263
+ accent: "bg-primary/10 hover:bg-primary/15",
262
264
  neutral: "",
263
265
  };
264
266
  export function rowToneClass(tone) {
@@ -1,6 +1,7 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useCallback, useEffect, useRef, useState } from "react";
3
- import { entityFromQueryType, useDispatcher, useLiveEvents, useTranslation } from "../../index.js";
3
+ import { subscribeToEntities, useLiveEntitiesForQuery } from "../../hooks/live-query-entities.js";
4
+ import { useDispatcher, useLiveEvents, useTranslation } from "../../index.js";
4
5
  import { EmptyState, ErrorState, LoadingState } from "./states.js";
5
6
  /** Cursor-paginated scroll list (mail inbox, activity feeds) — loads the
6
7
  * next page when the end sentinel becomes visible (IntersectionObserver),
@@ -11,6 +12,7 @@ export function InfinityList({ query, payload, pageSize = 50, rows, nextCursor,
11
12
  const dispatcher = useDispatcher();
12
13
  const t = useTranslation();
13
14
  const subscribeLive = useLiveEvents();
15
+ const liveEntities = useLiveEntitiesForQuery(query);
14
16
  const [state, setState] = useState({ kind: "loading" });
15
17
  const sentinelRef = useRef(null);
16
18
  const payloadKey = JSON.stringify(payload ?? {});
@@ -136,9 +138,8 @@ export function InfinityList({ query, payload, pageSize = 50, rows, nextCursor,
136
138
  // skip: live mode off, no SSE subscription needed
137
139
  if (!live)
138
140
  return;
139
- const entity = entityFromQueryType(query);
140
- // skip: query type has no mapped entity, nothing to subscribe to
141
- if (entity === undefined)
141
+ // skip: no entity to listen on, nothing to subscribe to
142
+ if (liveEntities.length === 0)
142
143
  return;
143
144
  const runRefresh = () => {
144
145
  liveInFlightRef.current = true;
@@ -162,7 +163,7 @@ export function InfinityList({ query, payload, pageSize = 50, rows, nextCursor,
162
163
  runRefresh();
163
164
  }, 250);
164
165
  };
165
- const unsubscribe = subscribeLive(entity, scheduleRefresh);
166
+ const unsubscribe = subscribeToEntities(subscribeLive, liveEntities, scheduleRefresh);
166
167
  return () => {
167
168
  unsubscribe();
168
169
  if (liveTimerRef.current !== null) {
@@ -170,7 +171,7 @@ export function InfinityList({ query, payload, pageSize = 50, rows, nextCursor,
170
171
  liveTimerRef.current = null;
171
172
  }
172
173
  };
173
- }, [live, query, refreshFirstPage, subscribeLive]);
174
+ }, [live, liveEntities, refreshFirstPage, subscribeLive]);
174
175
  useEffect(() => {
175
176
  const sentinel = sentinelRef.current;
176
177
  // skip: no further page or not ready yet — observer not needed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer",
3
- "version": "0.391.1",
3
+ "version": "0.392.0",
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": "MIT",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -48,8 +48,8 @@
48
48
  }
49
49
  },
50
50
  "dependencies": {
51
- "@cosmicdrift/kumiko-framework": "0.391.1",
52
- "@cosmicdrift/kumiko-types": "0.391.1",
51
+ "@cosmicdrift/kumiko-framework": "0.392.0",
52
+ "@cosmicdrift/kumiko-types": "0.392.0",
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.391.1",
96
- "@cosmicdrift/kumiko-testing": "0.391.1",
95
+ "@cosmicdrift/kumiko-locale-de": "0.392.0",
96
+ "@cosmicdrift/kumiko-testing": "0.392.0",
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,46 @@
1
1
  [
2
+ {
3
+ "version": "0.392.0",
4
+ "type": "improvement",
5
+ "title": "The projectionDetail header badge shows the translated status",
6
+ "detail": "The header badge formats its text with header.statusFormat. Without it, the badge reuses the layout renderer of the status field when that is enumOption or i18nText, so header and body show the same text. Without either, the raw value is shown as before. The tone keeps resolving on the raw value."
7
+ },
8
+ {
9
+ "version": "0.392.0",
10
+ "type": "fix",
11
+ "title": "The shell header title follows the active workspace",
12
+ "detail": "When two navs point at the same screen, the breadcrumb now takes the label of the nav the sidebar shows for the active workspace. Before, the first nav in the app won, so the title could differ from the sidebar entry. Without a matching nav in the workspace, the old lookup applies."
13
+ },
14
+ {
15
+ "version": "0.392.0",
16
+ "type": "improvement",
17
+ "title": "The detail header keeps inlineActions as buttons",
18
+ "detail": "HeaderActionsBar takes inlineActionIds and renders those actions as buttons after the primary action; only the remaining actions go into the overflow menu, and no menu is shown when none remain."
19
+ },
20
+ {
21
+ "version": "0.392.0",
22
+ "type": "improvement",
23
+ "title": "The relatedList empty state follows the parent record",
24
+ "detail": "RelatedListSection evaluates emptyState.variants against the parent record and shows the first matching text. The headless view model translates the variant texts."
25
+ },
26
+ {
27
+ "version": "0.392.0",
28
+ "type": "improvement",
29
+ "title": "English header comments in the renderer e2e config",
30
+ "detail": "Comment-only change in the renderer Playwright config and the demo dev bootstrap; no runtime behavior changes."
31
+ },
32
+ {
33
+ "version": "0.392.0",
34
+ "type": "improvement",
35
+ "title": "A drawer-hosted actionForm follows its redirect",
36
+ "detail": "After a successful submit the drawer closes, the host refetches, and a declared redirect then navigates like on the full page, without returnTo. A redirect that points at the screen the drawer sits on (no record id, no tab) does not navigate, so filters and scroll position stay. Behavior change: before, a drawer ignored redirect."
37
+ },
38
+ {
39
+ "version": "0.392.0",
40
+ "type": "improvement",
41
+ "title": "Live queries follow the handler's liveEntities",
42
+ "detail": "useQuery({ live: true }) and InfinityList refetch on signals for the entity in the query name and for every entity the query handler declares in liveEntities, in both the declared and the kebab-case form. The app schema carries them per feature as queryLiveEntities, and a tenant feature toggle strips them with the feature. A query that reads a second entity no longer needs a name under that entity to stay live."
43
+ },
2
44
  {
3
45
  "version": "0.391.0",
4
46
  "type": "improvement",