@cosmicdrift/kumiko-renderer 0.233.0 → 0.235.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.
@@ -6,6 +6,7 @@ import type {
6
6
  EntityDefinition,
7
7
  EntityEditScreenDefinition,
8
8
  EntityListScreenDefinition,
9
+ IconKey,
9
10
  ListFacetSpec,
10
11
  ProjectionDetailScreenDefinition,
11
12
  ProjectionListScreenDefinition,
@@ -35,7 +36,14 @@ import { useUserRoles } from "../context/user-roles-context";
35
36
  import { type ListSort, useListUrlState } from "../hooks/use-list-url-state";
36
37
  import { useQuery } from "../hooks/use-query";
37
38
  import { useTranslation } from "../i18n";
38
- import { type DataTableFacet, type DataTableRowAction, usePrimitives } from "../primitives";
39
+ import {
40
+ type DataTableFacet,
41
+ type DataTableRowAction,
42
+ type DataTableRowActionMode,
43
+ shouldRenderActionsIconOnly,
44
+ statusToneForValue,
45
+ usePrimitives,
46
+ } from "../primitives";
39
47
  import { synthesizeActionFormEntity, synthesizeActionFormScreen } from "./action-form-shim";
40
48
  import { useAppFeatures } from "./app-features-context";
41
49
  import { synthesizeConfigEditEntity, synthesizeConfigEditScreen } from "./config-edit-shim";
@@ -53,6 +61,7 @@ import { synthesizeProjectionEntity, synthesizeProjectionScreen } from "./projec
53
61
  import { lastSegment, toKebab } from "./qn";
54
62
  import { featureNameFromQualifiedScreenId, qualifyScreenId } from "./qualify-screen-id";
55
63
  import { screenAccessAllows } from "./screen-access";
64
+ import { SecretsEditBody } from "./secrets-edit-body";
56
65
  import { dispatcherErrorText, WriteFailedError } from "./write-failed-error";
57
66
 
58
67
  function evalRowExtractor(
@@ -69,6 +78,63 @@ function isWriteHandlerRowAction(action: RowAction): action is RowActionWriteHan
69
78
  return action.kind === "writeHandler" || action.kind === undefined;
70
79
  }
71
80
 
81
+ // Part B (fw-ui-defaults): id-derived default icon for actions that never
82
+ // declared one — a screen author still gets a recognizable glyph instead of
83
+ // a bare label. Checked against the actually registered IconKey vocabulary
84
+ // (nav-icon.ts) — no entry for verbs without a matching icon (e.g. "start",
85
+ // "pause").
86
+ const ACTION_ICON_BY_ID: Readonly<Partial<Record<string, IconKey>>> = {
87
+ delete: "trash",
88
+ edit: "pencil",
89
+ create: "plus",
90
+ new: "plus",
91
+ add: "plus",
92
+ view: "eye",
93
+ open: "eye",
94
+ cancel: "x",
95
+ reject: "x",
96
+ complete: "check",
97
+ resolve: "check",
98
+ approve: "check",
99
+ archive: "archive",
100
+ publish: "upload",
101
+ duplicate: "copy",
102
+ copy: "copy",
103
+ download: "download",
104
+ refresh: "refresh",
105
+ retry: "refresh",
106
+ settings: "settings",
107
+ share: "share",
108
+ send: "send",
109
+ };
110
+
111
+ // Ids are kebab-case (RowAction.id doc) — a compound id whose full form has
112
+ // no entry falls back to its last segment ("order-ship" -> "ship").
113
+ function kebabLastSegment(id: string): string {
114
+ const idx = id.lastIndexOf("-");
115
+ return idx === -1 ? id : id.slice(idx + 1);
116
+ }
117
+
118
+ // Resolution order: author-declared `icon` wins, then the id-derived
119
+ // default (full id, then its last kebab segment). `declared` is `undefined`
120
+ // for ToolbarAction, which has no author-facing icon field.
121
+ function resolveActionIcon(id: string, declared?: IconKey): IconKey | undefined {
122
+ if (declared !== undefined) return declared;
123
+ return ACTION_ICON_BY_ID[id] ?? ACTION_ICON_BY_ID[kebabLastSegment(id)];
124
+ }
125
+
126
+ // Row-action column mode for a resolved action set: a group where every
127
+ // member carries an icon renders inline so `shouldRenderActionsIconOnly`
128
+ // can collapse it to icon-only buttons (fw#2580). Anything else keeps the
129
+ // DataTable's adaptive default (kebab past two actions) — inline text
130
+ // buttons for an icon-less group are the very thing the collapse avoids.
131
+ function rowActionModeFor(
132
+ actions: readonly DataTableRowAction[] | undefined,
133
+ ): DataTableRowActionMode | undefined {
134
+ if (actions === undefined || !shouldRenderActionsIconOnly(actions)) return undefined;
135
+ return "inline";
136
+ }
137
+
72
138
  // KumikoScreen picks up a ScreenDefinition from the schema by qn and
73
139
  // routes it to the right renderer based on `screen.type`. Command
74
140
  // qualification (`<feature>:write:<entity>:create` etc.) happens here
@@ -197,6 +263,8 @@ export function KumikoScreen({
197
263
  return <ActionFormBody schema={schema} screen={screen} translate={translate} />;
198
264
  case "configEdit":
199
265
  return <ConfigEditBody schema={schema} screen={screen} translate={translate} />;
266
+ case "secretsEdit":
267
+ return <SecretsEditBody screen={screen} translate={translate} />;
200
268
  case "custom":
201
269
  return <CustomScreenBody screenId={screen.id} featureName={schema.featureName} />;
202
270
  }
@@ -544,6 +612,10 @@ function EntityEditCreateBody({
544
612
  },
545
613
  [nav, screen.redirect, navigateToList, onSaved],
546
614
  );
615
+ // Deliberately no `actions` prop here: `screen.actions` targets an
616
+ // EXISTING record (publish/archive/duplicate and friends), which the
617
+ // create branch has none of yet — see EntityEditUpdateForm for the
618
+ // wired-up counterpart.
547
619
  return (
548
620
  <RenderEdit
549
621
  screen={screen}
@@ -712,7 +784,99 @@ function EntityEditUpdateForm({
712
784
 
713
785
  const nav = useNav();
714
786
  const dispatcher = useDispatcher();
787
+ const t = useTranslation();
788
+ const effectiveTranslate = translate ?? t;
715
789
  const navigateToList = useNavigateToListAfter(schema, screen.entity);
790
+ // Header action buttons (fw entityEdit-actions) — same shape/dispatch
791
+ // pattern as ProjectionDetailBody.headerActions above (the edited record
792
+ // stands in for the "row"), minus the cross-feature defaultEditAction
793
+ // lookup that doesn't apply here (this screen already IS the edit form).
794
+ const headerActions = useMemo((): readonly RenderEditAction[] | undefined => {
795
+ const out: RenderEditAction[] = [];
796
+ for (const action of screen.actions ?? []) {
797
+ if (action.visible !== undefined && !evalFieldCondition(action.visible, record)) {
798
+ continue;
799
+ }
800
+ if (action.kind === "navigate") {
801
+ const runParams = (): void => {
802
+ const params =
803
+ action.params !== undefined ? evalRowExtractor(action.params, record) : undefined;
804
+ if (params !== undefined) {
805
+ nav.setSearchParams(stringifyNavParams(params));
806
+ }
807
+ };
808
+ const actionIcon = resolveActionIcon(action.id, action.icon);
809
+ if (action.entity !== undefined) {
810
+ const targetEntity = action.entity;
811
+ const id = action.entityId !== undefined ? String(record[action.entityId] ?? "") : "";
812
+ out.push({
813
+ id: action.id,
814
+ label: effectiveTranslate(action.label),
815
+ ...(action.style !== undefined && { style: action.style }),
816
+ ...(actionIcon !== undefined && { icon: actionIcon }),
817
+ onPress: () => {
818
+ if (id === "") return;
819
+ nav.navigate({ entity: targetEntity, id });
820
+ runParams();
821
+ },
822
+ });
823
+ } else if (action.screen !== undefined) {
824
+ // Default entityId for a screen-target: the currently edited
825
+ // record's own id — an entityEdit header action has no other
826
+ // "row" to derive one from (unlike ProjectionDetailBody, which
827
+ // needs the same-entity detailFor lookup instead).
828
+ const explicit =
829
+ action.entityId !== undefined ? String(record[action.entityId] ?? "") : undefined;
830
+ const navEntityId = explicit ?? entityId;
831
+ const targetScreen = action.screen;
832
+ out.push({
833
+ id: action.id,
834
+ label: effectiveTranslate(action.label),
835
+ ...(action.style !== undefined && { style: action.style }),
836
+ ...(actionIcon !== undefined && { icon: actionIcon }),
837
+ onPress: () => {
838
+ nav.navigate({
839
+ screenId: targetScreen,
840
+ ...(navEntityId !== undefined && navEntityId !== "" && { entityId: navEntityId }),
841
+ });
842
+ runParams();
843
+ },
844
+ });
845
+ }
846
+ continue;
847
+ }
848
+ // writeHandler — same dispatch/reload/failure-surfacing pattern as
849
+ // ProjectionDetailBody's headerActions above.
850
+ const writeAction = action;
851
+ out.push({
852
+ id: writeAction.id,
853
+ label: effectiveTranslate(writeAction.label),
854
+ ...(writeAction.style !== undefined && { style: writeAction.style }),
855
+ icon: resolveActionIcon(writeAction.id, writeAction.icon),
856
+ ...(writeAction.confirm !== undefined && {
857
+ confirm: effectiveTranslate(writeAction.confirm),
858
+ }),
859
+ ...(writeAction.confirmLabel !== undefined && {
860
+ confirmLabel: effectiveTranslate(writeAction.confirmLabel),
861
+ }),
862
+ onPress: async () => {
863
+ const payload =
864
+ writeAction.payload !== undefined
865
+ ? evalRowExtractor(writeAction.payload, record)
866
+ : { id: entityId };
867
+ const result = await dispatcher.write(writeAction.handler, payload);
868
+ if (!result.isSuccess) {
869
+ throw new WriteFailedError(
870
+ result.error,
871
+ dispatcherErrorText(result.error, effectiveTranslate),
872
+ );
873
+ }
874
+ await onReload();
875
+ },
876
+ });
877
+ }
878
+ return out.length > 0 ? out : undefined;
879
+ }, [screen.actions, effectiveTranslate, nav, dispatcher, record, entityId, onReload]);
716
880
  const handleSubmitted = useCallback(
717
881
  (result: SubmitResult<unknown>) => {
718
882
  if (!result.isSuccess) return;
@@ -760,6 +924,7 @@ function EntityEditUpdateForm({
760
924
  {...(screen.submitLabel !== undefined && { submitLabel: screen.submitLabel })}
761
925
  {...(translate !== undefined && { translate })}
762
926
  {...(onCopyLink !== undefined && { onCopyLink })}
927
+ {...(headerActions !== undefined && { actions: headerActions })}
763
928
  />
764
929
  );
765
930
  }
@@ -1450,10 +1615,12 @@ function EntityListBody({
1450
1615
  if (action.kind === "navigate") {
1451
1616
  const navigateAction = action;
1452
1617
  const actionVisible = action.visible;
1618
+ const actionIcon = resolveActionIcon(action.id, action.icon);
1453
1619
  return {
1454
1620
  id: action.id,
1455
1621
  label: effectiveTranslate(action.label),
1456
1622
  ...(action.style !== undefined && { style: action.style }),
1623
+ ...(actionIcon !== undefined && { icon: actionIcon }),
1457
1624
  onTrigger: (row: ListRowViewModel) => runNavigate(navigateAction, row),
1458
1625
  ...(actionVisible !== undefined && {
1459
1626
  isVisible: (row: ListRowViewModel) => evalFieldCondition(actionVisible, row.values),
@@ -1468,6 +1635,7 @@ function EntityListBody({
1468
1635
  id: writeAction.id,
1469
1636
  label: effectiveTranslate(writeAction.label),
1470
1637
  style: writeAction.style,
1638
+ icon: resolveActionIcon(writeAction.id, writeAction.icon),
1471
1639
  confirm:
1472
1640
  writeAction.confirm !== undefined ? effectiveTranslate(writeAction.confirm) : undefined,
1473
1641
  confirmLabel:
@@ -1501,6 +1669,12 @@ function EntityListBody({
1501
1669
  .filter((a: DataTableRowAction | null): a is DataTableRowAction => a !== null);
1502
1670
  }, [screen.rowActions, effectiveTranslate, dispatcher, runNavigate, refreshRowsAfterWrite]);
1503
1671
 
1672
+ // Row actions that all resolve an icon render inline and collapse to
1673
+ // icon-only (fw#2580) — the adaptive default would bury more than two of
1674
+ // them in a kebab menu. A group with an icon-less member stays adaptive so
1675
+ // it never degrades into wall-to-wall text buttons.
1676
+ const rowActionMode = rowActionModeFor(rowActions);
1677
+
1504
1678
  // ToolbarActions: Schema → Resolved-Form (analog rowActions).
1505
1679
  // navigate-kind → useNav().navigate({ screenId }), writeHandler-kind
1506
1680
  // → dispatcher.write(handler, payload?()). KumikoScreen kennt schon
@@ -1509,11 +1683,13 @@ function EntityListBody({
1509
1683
  if (screen.toolbarActions === undefined) return undefined;
1510
1684
  return screen.toolbarActions
1511
1685
  .map((action: ToolbarAction): ToolbarActionButton | null => {
1686
+ const actionIcon = resolveActionIcon(action.id);
1512
1687
  if (action.kind === "navigate") {
1513
1688
  return {
1514
1689
  id: action.id,
1515
1690
  label: effectiveTranslate(action.label),
1516
1691
  ...(action.style !== undefined && { style: action.style }),
1692
+ ...(actionIcon !== undefined && { icon: actionIcon }),
1517
1693
  onTrigger: () => nav.navigate({ screenId: action.screen }),
1518
1694
  };
1519
1695
  }
@@ -1522,6 +1698,7 @@ function EntityListBody({
1522
1698
  id: action.id,
1523
1699
  label: effectiveTranslate(action.label),
1524
1700
  ...(action.style !== undefined && { style: action.style }),
1701
+ ...(actionIcon !== undefined && { icon: actionIcon }),
1525
1702
  onTrigger: () => openDrawer(action),
1526
1703
  };
1527
1704
  }
@@ -1533,6 +1710,7 @@ function EntityListBody({
1533
1710
  id: action.id,
1534
1711
  label: effectiveTranslate(action.label),
1535
1712
  ...(action.style !== undefined && { style: action.style }),
1713
+ ...(actionIcon !== undefined && { icon: actionIcon }),
1536
1714
  ...(action.confirm !== undefined && { confirm: effectiveTranslate(action.confirm) }),
1537
1715
  ...(action.confirmLabel !== undefined && {
1538
1716
  confirmLabel: effectiveTranslate(action.confirmLabel),
@@ -1638,6 +1816,7 @@ function EntityListBody({
1638
1816
  onSortChange={urlState.setSort}
1639
1817
  {...(pager !== undefined && { pager })}
1640
1818
  {...(rowActions !== undefined && { rowActions })}
1819
+ {...(rowActionMode !== undefined && { rowActionMode })}
1641
1820
  {...(toolbarActions !== undefined && toolbarActions.length > 0 && { toolbarActions })}
1642
1821
  {...(useInfinite && {
1643
1822
  onReachEnd: loadMore,
@@ -1813,10 +1992,12 @@ function ProjectionListBody({
1813
1992
  if (action.kind === "navigate") {
1814
1993
  const navigateAction = action;
1815
1994
  const visible = action.visible;
1995
+ const actionIcon = resolveActionIcon(action.id, action.icon);
1816
1996
  out.push({
1817
1997
  id: action.id,
1818
1998
  label: effectiveTranslate(action.label),
1819
1999
  ...(action.style !== undefined && { style: action.style }),
2000
+ ...(actionIcon !== undefined && { icon: actionIcon }),
1820
2001
  onTrigger: (row: ListRowViewModel) => runNavigate(navigateAction, row),
1821
2002
  ...(visible !== undefined && {
1822
2003
  isVisible: (row: ListRowViewModel) => evalFieldCondition(visible, row.values),
@@ -1834,6 +2015,7 @@ function ProjectionListBody({
1834
2015
  id: writeAction.id,
1835
2016
  label: effectiveTranslate(writeAction.label),
1836
2017
  ...(writeAction.style !== undefined && { style: writeAction.style }),
2018
+ icon: resolveActionIcon(writeAction.id, writeAction.icon),
1837
2019
  ...(writeAction.confirm !== undefined && {
1838
2020
  confirm: effectiveTranslate(writeAction.confirm),
1839
2021
  }),
@@ -1863,16 +2045,22 @@ function ProjectionListBody({
1863
2045
  return out.length > 0 ? out : undefined;
1864
2046
  }, [screen.rowActions, effectiveTranslate, runNavigate, dispatcher, rowsQuery.refetch]);
1865
2047
 
2048
+ // Same icon-only collapse as entityList (fw#2580) — projectionList rows go
2049
+ // through the identical RenderList/DataTable path.
2050
+ const rowActionMode = rowActionModeFor(rowActions);
2051
+
1866
2052
  const toolbarActions = useMemo((): readonly ToolbarActionButton[] | undefined => {
1867
2053
  if (screen.toolbarActions === undefined) return undefined;
1868
2054
  const out: ToolbarActionButton[] = [];
1869
2055
  for (const action of screen.toolbarActions) {
2056
+ const actionIcon = resolveActionIcon(action.id);
1870
2057
  if (action.kind === "navigate") {
1871
2058
  const target = action.screen;
1872
2059
  out.push({
1873
2060
  id: action.id,
1874
2061
  label: effectiveTranslate(action.label),
1875
2062
  ...(action.style !== undefined && { style: action.style }),
2063
+ ...(actionIcon !== undefined && { icon: actionIcon }),
1876
2064
  onTrigger: () => nav.navigate({ screenId: target }),
1877
2065
  });
1878
2066
  continue;
@@ -1882,6 +2070,7 @@ function ProjectionListBody({
1882
2070
  id: action.id,
1883
2071
  label: effectiveTranslate(action.label),
1884
2072
  ...(action.style !== undefined && { style: action.style }),
2073
+ ...(actionIcon !== undefined && { icon: actionIcon }),
1885
2074
  onTrigger: () => openDrawer(action),
1886
2075
  });
1887
2076
  continue;
@@ -1892,6 +2081,7 @@ function ProjectionListBody({
1892
2081
  id: action.id,
1893
2082
  label: effectiveTranslate(action.label),
1894
2083
  ...(action.style !== undefined && { style: action.style }),
2084
+ ...(actionIcon !== undefined && { icon: actionIcon }),
1895
2085
  ...(action.confirm !== undefined && { confirm: effectiveTranslate(action.confirm) }),
1896
2086
  ...(action.confirmLabel !== undefined && {
1897
2087
  confirmLabel: effectiveTranslate(action.confirmLabel),
@@ -1977,6 +2167,7 @@ function ProjectionListBody({
1977
2167
  onSortChange={urlState.setSort}
1978
2168
  {...(pager !== undefined && { pager })}
1979
2169
  {...(rowActions !== undefined && { rowActions })}
2170
+ {...(rowActionMode !== undefined && { rowActionMode })}
1980
2171
  {...(toolbarActions !== undefined && { toolbarActions })}
1981
2172
  {...(translate !== undefined && { translate })}
1982
2173
  {...(wrappedOnRowClick !== undefined && { onRowClick: wrappedOnRowClick })}
@@ -2027,22 +2218,22 @@ function ProjectionDetailBody({
2027
2218
  readonly translate?: Translate;
2028
2219
  readonly entityId?: string;
2029
2220
  }): ReactNode {
2030
- const { Banner, Text, Heading, Grid, GridCell, Tabs, StatusBadge, Metric } = usePrimitives();
2221
+ const { Banner, Text, Heading, Grid, GridCell, Card, Tabs, StatusBadge, Metric } =
2222
+ usePrimitives();
2031
2223
  const t = useTranslation();
2032
2224
  const effectiveTranslate = translate ?? t;
2033
2225
  const nav = useNav();
2034
2226
  const idParam = screen.idParam ?? "id";
2035
2227
  const isTabsMode = screen.layout.mode === "tabs";
2036
2228
  const activeSection = useMemo(() => {
2037
- if (!isTabsMode) return undefined;
2229
+ if (!isTabsMode || Tabs === undefined) return undefined;
2038
2230
  const tabParam = nav.searchParams["tab"];
2039
2231
  return (
2040
2232
  screen.layout.sections.find((section) => section.id === tabParam) ?? screen.layout.sections[0]
2041
2233
  );
2042
- }, [isTabsMode, screen.layout.sections, nav.searchParams]);
2043
- // Tabs is an optional Core-Primitive (additive rollout) same "skip +
2044
- // warn once" precedent as Drawer above, instead of crashing when a web
2045
- // app hasn't upgraded its createKumikoApp wiring yet.
2234
+ }, [isTabsMode, Tabs, screen.layout.sections, nav.searchParams]);
2235
+ // Tabs is an optional Core-Primitive: without it the screen falls back to
2236
+ // the stacked all-sections layout instead of silently truncating to section 1.
2046
2237
  useEffect(() => {
2047
2238
  if (isTabsMode && Tabs === undefined) {
2048
2239
  // biome-ignore lint/suspicious/noConsole: dev-warning for a setup error
@@ -2100,6 +2291,7 @@ function ProjectionDetailBody({
2100
2291
  return {
2101
2292
  id: "edit",
2102
2293
  label: effectiveTranslate("kumiko.actions.edit"),
2294
+ icon: resolveActionIcon("edit"),
2103
2295
  onPress: () =>
2104
2296
  nav.navigate({ screenId: targetScreenId, ...(entityId !== undefined && { entityId }) }),
2105
2297
  };
@@ -2132,10 +2324,12 @@ function ProjectionDetailBody({
2132
2324
  // expliziten entityId für projectionDetail-entity-Targets.
2133
2325
  const targetEntity = action.entity;
2134
2326
  const id = action.entityId !== undefined ? String(record[action.entityId] ?? "") : "";
2327
+ const actionIcon = resolveActionIcon(action.id, action.icon);
2135
2328
  out.push({
2136
2329
  id: action.id,
2137
2330
  label: effectiveTranslate(action.label),
2138
2331
  ...(action.style !== undefined && { style: action.style }),
2332
+ ...(actionIcon !== undefined && { icon: actionIcon }),
2139
2333
  onPress: () => {
2140
2334
  if (id === "") return;
2141
2335
  nav.navigate({ entity: targetEntity, id });
@@ -2163,10 +2357,12 @@ function ProjectionDetailBody({
2163
2357
  const fallback = targetIsEntityEditSameEntity ? String(record["id"] ?? "") : undefined;
2164
2358
  const navEntityId = explicit ?? fallback;
2165
2359
  const targetScreen = action.screen;
2360
+ const actionIcon = resolveActionIcon(action.id, action.icon);
2166
2361
  out.push({
2167
2362
  id: action.id,
2168
2363
  label: effectiveTranslate(action.label),
2169
2364
  ...(action.style !== undefined && { style: action.style }),
2365
+ ...(actionIcon !== undefined && { icon: actionIcon }),
2170
2366
  onPress: () => {
2171
2367
  nav.navigate({
2172
2368
  screenId: targetScreen,
@@ -2186,6 +2382,7 @@ function ProjectionDetailBody({
2186
2382
  id: writeAction.id,
2187
2383
  label: effectiveTranslate(writeAction.label),
2188
2384
  ...(writeAction.style !== undefined && { style: writeAction.style }),
2385
+ icon: resolveActionIcon(writeAction.id, writeAction.icon),
2189
2386
  ...(writeAction.confirm !== undefined && {
2190
2387
  confirm: effectiveTranslate(writeAction.confirm),
2191
2388
  }),
@@ -2261,52 +2458,60 @@ function ProjectionDetailBody({
2261
2458
  const header = screen.header;
2262
2459
  const headerContent = (
2263
2460
  <>
2264
- {header !== undefined && (
2265
- <>
2266
- <Heading variant="page" testId="kumiko-screen-projection-detail-title">
2267
- {String(record[header.title] ?? "")}
2268
- </Heading>
2269
- {(header.subtitle !== undefined || header.status !== undefined) && (
2270
- <Grid columns="auto">
2271
- {header.subtitle !== undefined && (
2272
- <Text variant="muted" testId="kumiko-screen-projection-detail-subtitle">
2273
- {String(record[header.subtitle] ?? "")}
2274
- </Text>
2461
+ {(hasHeader || hasMetrics) && (
2462
+ <Card>
2463
+ {header !== undefined && (
2464
+ <>
2465
+ <Heading variant="page" testId="kumiko-screen-projection-detail-title">
2466
+ {String(record[header.title] ?? "")}
2467
+ </Heading>
2468
+ {(header.subtitle !== undefined || header.status !== undefined) && (
2469
+ <Grid columns="auto">
2470
+ {header.subtitle !== undefined && (
2471
+ <Text variant="muted" testId="kumiko-screen-projection-detail-subtitle">
2472
+ {String(record[header.subtitle] ?? "")}
2473
+ </Text>
2474
+ )}
2475
+ {header.status !== undefined &&
2476
+ (StatusBadge !== undefined ? (
2477
+ <StatusBadge
2478
+ value={String(record[header.status] ?? "")}
2479
+ tone={statusToneForValue(String(record[header.status] ?? ""))}
2480
+ testId="kumiko-screen-projection-detail-status"
2481
+ />
2482
+ ) : (
2483
+ <Text testId="kumiko-screen-projection-detail-status">
2484
+ {String(record[header.status] ?? "")}
2485
+ </Text>
2486
+ ))}
2487
+ </Grid>
2275
2488
  )}
2276
- {header.status !== undefined &&
2277
- (StatusBadge !== undefined ? (
2278
- <StatusBadge
2279
- value={String(record[header.status] ?? "")}
2280
- testId="kumiko-screen-projection-detail-status"
2281
- />
2489
+ </>
2490
+ )}
2491
+ {hasMetrics && (
2492
+ <Grid
2493
+ columns={screen.metrics?.length ?? 1}
2494
+ testId="kumiko-screen-projection-detail-metrics"
2495
+ >
2496
+ {screen.metrics?.map((metric) => {
2497
+ const labelKey = screen.fieldLabels?.[metric];
2498
+ const label = labelKey !== undefined ? effectiveTranslate(labelKey) : metric;
2499
+ const value = String(record[metric] ?? "");
2500
+ const testId = `kumiko-screen-projection-detail-metric-${metric}`;
2501
+ return Metric !== undefined ? (
2502
+ <Metric key={metric} label={label} value={value} testId={testId} />
2282
2503
  ) : (
2283
- <Text testId="kumiko-screen-projection-detail-status">
2284
- {String(record[header.status] ?? "")}
2285
- </Text>
2286
- ))}
2504
+ <GridCell key={metric}>
2505
+ <Text variant="small" testId={`${testId}-label`}>
2506
+ {label}
2507
+ </Text>
2508
+ <Text testId={`${testId}-value`}>{value}</Text>
2509
+ </GridCell>
2510
+ );
2511
+ })}
2287
2512
  </Grid>
2288
2513
  )}
2289
- </>
2290
- )}
2291
- {hasMetrics && (
2292
- <Grid columns="auto" testId="kumiko-screen-projection-detail-metrics">
2293
- {screen.metrics?.map((metric) => {
2294
- const labelKey = screen.fieldLabels?.[metric];
2295
- const label = labelKey !== undefined ? effectiveTranslate(labelKey) : metric;
2296
- const value = String(record[metric] ?? "");
2297
- const testId = `kumiko-screen-projection-detail-metric-${metric}`;
2298
- return Metric !== undefined ? (
2299
- <Metric key={metric} label={label} value={value} testId={testId} />
2300
- ) : (
2301
- <GridCell key={metric}>
2302
- <Text variant="small" testId={`${testId}-label`}>
2303
- {label}
2304
- </Text>
2305
- <Text testId={`${testId}-value`}>{value}</Text>
2306
- </GridCell>
2307
- );
2308
- })}
2309
- </Grid>
2514
+ </Card>
2310
2515
  )}
2311
2516
  {hasTabs && activeSection !== undefined && (
2312
2517
  <Tabs
@@ -2331,7 +2536,7 @@ function ProjectionDetailBody({
2331
2536
  customSubmit={async () => ({ isSuccess: true, validationBlocked: false, data: undefined })}
2332
2537
  {...(headerActions !== undefined && { actions: headerActions })}
2333
2538
  {...(translate !== undefined && { translate })}
2334
- {...(isTabsMode && { hideSectionTitles: true })}
2539
+ {...(hasTabs && { hideSectionTitles: true })}
2335
2540
  {...((hasHeader || hasMetrics || hasTabs) && { headerRegion: headerContent })}
2336
2541
  valueDisplay={screen.valueDisplay ?? "text"}
2337
2542
  />