@cosmicdrift/kumiko-renderer 0.346.0 → 0.348.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.
@@ -13,8 +13,10 @@ import { RenderList } from "../components/render-list.js";
13
13
  import { useDispatcher, useOptionalDispatcher } from "../context/dispatcher-context.js";
14
14
  import { useIsEmbeddedScreen } from "../context/embedded-screen-context.js";
15
15
  import { useUserRoles } from "../context/user-roles-context.js";
16
+ import { REFERENCE_LIST_LOOKUP_LIMIT } from "../hooks/reference-limits.js";
16
17
  import { PAGE_SIZE_OPTIONS, useListUrlState } from "../hooks/use-list-url-state.js";
17
18
  import { useQuery } from "../hooks/use-query.js";
19
+ import { referenceLookupSource } from "../hooks/use-reference-lookup.js";
18
20
  import { useLocale, useOptionalTimeZone, useTranslation } from "../i18n.js";
19
21
  import { InsideDrawerProvider, useInsideDrawer } from "../inside-drawer.js";
20
22
  import { PageHeaderSlotAvailableProvider, usePageHeaderCompact, usePageHeaderSlotAvailable, } from "../page-header-slot.js";
@@ -86,7 +88,8 @@ export function KumikoScreen({ schema, qn, translate, entityId, onRowClick, onCo
86
88
  return _jsx(ReturnHostProvider, { value: ownHost, children: body });
87
89
  }
88
90
  function ScreenVisibilityGate({ featureName, visibleWhen, fallback, translate, children, }) {
89
- const { Banner } = usePrimitives();
91
+ const { Banner, PageHeader } = usePrimitives();
92
+ const pageHeaderSlotAvailable = usePageHeaderSlotAvailable();
90
93
  const t = useTranslation();
91
94
  const result = useQuery(visibleWhen.query, {}, { live: true });
92
95
  const verdict = evalVisibleWhen(visibleWhen, result);
@@ -95,10 +98,9 @@ function ScreenVisibilityGate({ featureName, visibleWhen, fallback, translate, c
95
98
  if (verdict === "loading") {
96
99
  return (_jsx(Banner, { padded: true, variant: "loading", testId: "kumiko-screen-loading", children: "Loading\u2026" }));
97
100
  }
98
- if (fallback !== undefined) {
99
- return _jsx(FallbackScreen, { featureName: featureName, fallback: fallback, translate: translate });
100
- }
101
- return (_jsx(Banner, { padded: true, variant: "info", testId: "kumiko-screen-unavailable", children: t("kumiko.screen.unavailable") }));
101
+ // The route still names the locked screen; its breadcrumb must not label what stands in for it.
102
+ const hideLockedBreadcrumb = PageHeader !== undefined && pageHeaderSlotAvailable ? _jsx(PageHeader, { hideBreadcrumb: true }) : null;
103
+ return (_jsxs(_Fragment, { children: [hideLockedBreadcrumb, fallback !== undefined ? (_jsx(FallbackScreen, { featureName: featureName, fallback: fallback, translate: translate })) : (_jsx(Banner, { padded: true, variant: "info", testId: "kumiko-screen-unavailable", children: t("kumiko.screen.unavailable") }))] }));
102
104
  }
103
105
  function FallbackScreen({ featureName, fallback, translate, }) {
104
106
  const target = useEmbeddedScreen(featureName, fallback);
@@ -125,9 +127,12 @@ function renderScreenBody({ schema, screen, translate, entityId, onRowClick, onC
125
127
  case "secretMint":
126
128
  return _jsx(SecretMintBody, { schema: schema, screen: screen, translate: translate });
127
129
  case "configEdit":
128
- return _jsx(ConfigEditBody, { schema: schema, screen: screen, translate: translate });
130
+ // Keyed by screen id: the form state is seeded once at mount, so switching
131
+ // between two settings screens must remount instead of carrying the
132
+ // previous screen's (empty) field state over.
133
+ return (_jsx(ConfigEditBody, { schema: schema, screen: screen, translate: translate }, screen.id));
129
134
  case "secretsEdit":
130
- return _jsx(SecretsEditBody, { screen: screen, translate: translate });
135
+ return _jsx(SecretsEditBody, { screen: screen, translate: translate }, screen.id);
131
136
  case "custom":
132
137
  return _jsx(CustomScreenBody, { screenId: screen.id, featureName: schema.featureName });
133
138
  }
@@ -360,6 +365,27 @@ function formatSummaryValue(value, locale) {
360
365
  function formatSummaryParams(prefill, locale) {
361
366
  return Object.fromEntries(Object.entries(prefill ?? {}).map(([name, value]) => [name, formatSummaryValue(value, locale)]));
362
367
  }
368
+ // Placeholders of `successMessage`: formatted submitted values, with a
369
+ // reference field resolved to the chosen record's label. A lookup that fails or
370
+ // misses the row (the lookup list is capped) falls back to the raw id.
371
+ async function resolveSuccessMessageParams(screen, featureName, values, dispatcher, locale) {
372
+ const params = formatSummaryParams(values, locale);
373
+ await Promise.all(Object.entries(screen.fields).map(async ([name, def]) => {
374
+ const raw = values[name];
375
+ if (def.type !== "reference" || typeof raw !== "string" || raw === "")
376
+ return;
377
+ const target = parseRefTarget(def.entity, featureName);
378
+ const { queryQn, labelKey } = referenceLookupSource(target.featureName, target.entityName, def.labelField ?? "id");
379
+ const lookup = await dispatcher.query(queryQn, { limit: REFERENCE_LIST_LOOKUP_LIMIT });
380
+ if (!lookup.isSuccess)
381
+ return;
382
+ const row = lookup.data.rows.find((candidate) => candidate["id"] === raw);
383
+ const label = row?.[labelKey];
384
+ if (label !== undefined && label !== null)
385
+ params[name] = String(label);
386
+ }));
387
+ return params;
388
+ }
363
389
  function resolveActionFormSummary(summary, prefill, translate, locale) {
364
390
  if (summary === undefined)
365
391
  return undefined;
@@ -2323,13 +2349,34 @@ function ActionFormBody({ schema, screen, translate, initialOverrides, onSuccess
2323
2349
  const t = useTranslation();
2324
2350
  const effectiveTranslate = translate ?? t;
2325
2351
  const summary = useMemo(() => resolveActionFormSummary(screen.summary, initialOverrides, effectiveTranslate, locale), [screen.summary, initialOverrides, effectiveTranslate, locale]);
2326
- const handleSubmitted = useCallback((result) => {
2352
+ const dispatcher = useDispatcher();
2353
+ const [successText, setSuccessText] = useState(undefined);
2354
+ // A slow label lookup of an earlier submit must not overwrite the banner of a later one.
2355
+ const successSequence = useRef(0);
2356
+ useEffect(() => () => {
2357
+ successSequence.current += 1;
2358
+ }, []);
2359
+ const handleSubmitted = useCallback((result, values) => {
2360
+ successSequence.current += 1;
2361
+ const sequence = successSequence.current;
2362
+ setSuccessText(undefined);
2327
2363
  if (!result.isSuccess)
2328
2364
  return;
2329
2365
  if (onSuccess !== undefined) {
2330
2366
  onSuccess();
2331
2367
  return;
2332
2368
  }
2369
+ if (screen.successMessage !== undefined && screen.redirect === undefined) {
2370
+ const messageKey = screen.successMessage;
2371
+ const show = (params) => {
2372
+ if (successSequence.current === sequence) {
2373
+ setSuccessText(effectiveTranslate(messageKey, params));
2374
+ }
2375
+ };
2376
+ void resolveSuccessMessageParams(screen, schema.featureName, values, dispatcher, locale)
2377
+ .then(show)
2378
+ .catch(() => show(formatSummaryParams(values, locale)));
2379
+ }
2333
2380
  // Without a redirect the form stays put; returnTo only replaces an existing
2334
2381
  // navigation, and never one to a record screen (see redirectTargetsRecord).
2335
2382
  if (screen.redirect !== undefined) {
@@ -2341,7 +2388,17 @@ function ActionFormBody({ schema, screen, translate, initialOverrides, onSuccess
2341
2388
  nav.navigate({ screenId, ...(entityId !== undefined && { entityId }) });
2342
2389
  }
2343
2390
  }
2344
- }, [nav, screen.redirect, onSuccess, schema, appFeatures, returnTarget]);
2391
+ }, [
2392
+ nav,
2393
+ screen,
2394
+ onSuccess,
2395
+ schema,
2396
+ appFeatures,
2397
+ returnTarget,
2398
+ dispatcher,
2399
+ locale,
2400
+ effectiveTranslate,
2401
+ ]);
2345
2402
  // Cancel ist nur sinnvoll wenn ein Navigations-Ziel existiert —
2346
2403
  // sonst hätte der Button nirgendwo hin zu navigieren. cancelTarget
2347
2404
  // gewinnt über redirect; `false` schaltet den Button explizit ab
@@ -2366,7 +2423,9 @@ function ActionFormBody({ schema, screen, translate, initialOverrides, onSuccess
2366
2423
  // (inkl. searchParams-Prefill, z.B. ?incidentId=…) als initialValues
2367
2424
  // — anders als im entityEdit gibt es hier keinen record, aus dem
2368
2425
  // sie Kontext ziehen könnten.
2369
- extensionInitialValues: initial, writeCommand: screen.handler, payloadMode: "values", onSubmit: handleSubmitted, ...(handleCancel !== undefined && { onCancel: handleCancel }), ...(onDirtyChange !== undefined && { onDirtyChange }), ...((insideDrawer || screenFillsHeight(screen)) && { fillScreenHeight: true }), ...((screen.submitLabel ?? submitLabelFallback) !== undefined && {
2426
+ extensionInitialValues: initial, ...(successText !== undefined && {
2427
+ leadContent: (_jsx(Banner, { variant: "info", testId: "action-form-success", children: successText })),
2428
+ }), writeCommand: screen.handler, payloadMode: "values", onSubmit: handleSubmitted, ...(handleCancel !== undefined && { onCancel: handleCancel }), ...(onDirtyChange !== undefined && { onDirtyChange }), ...((insideDrawer || screenFillsHeight(screen)) && { fillScreenHeight: true }), ...((screen.submitLabel ?? submitLabelFallback) !== undefined && {
2370
2429
  submitLabel: screen.submitLabel ?? submitLabelFallback,
2371
2430
  }), ...(screen.submitStyle !== undefined && { submitVariant: screen.submitStyle }), ...(screen.footerActions !== undefined && { footerActions: screen.footerActions }), ...(summary !== undefined && { summary }), ...(translate !== undefined && { translate }) }));
2372
2431
  }
@@ -35,7 +35,8 @@ export type RenderEditProps<TValues extends FormValues, TCtx = unknown> = {
35
35
  readonly translate?: Translate;
36
36
  readonly ctx?: TCtx;
37
37
  readonly schema?: z.ZodType;
38
- readonly onSubmit?: (result: SubmitResult<unknown>) => void;
38
+ /** `values` are the form values at the moment the submit succeeded. */
39
+ readonly onSubmit?: (result: SubmitResult<unknown>, values: TValues) => void;
39
40
  readonly payloadMode?: "values" | "changes";
40
41
  readonly buildPayload?: (snapshot: FormSnapshot<TValues>) => unknown;
41
42
  /** Prefix to strip from server validation issue paths before mapping them
@@ -1001,8 +1001,9 @@ export function RenderEdit(props) {
1001
1001
  // skip: on entity-success-but-extension-failure the extension-error
1002
1002
  // banner is showing — don't notify (the caller navigates away on success
1003
1003
  // and would unmount it before the user sees the failure).
1004
- if (shouldNotifyCaller(result, extensionsPersisted))
1005
- onSubmit?.(result);
1004
+ if (shouldNotifyCaller(result, extensionsPersisted)) {
1005
+ onSubmit?.(result, controller.getSnapshot().values);
1006
+ }
1006
1007
  }
1007
1008
  finally {
1008
1009
  isSubmittingRef.current = false;
@@ -1,10 +1,12 @@
1
1
  import type { Dispatcher, DispatcherStatus } from "@cosmicdrift/kumiko-headless";
2
2
  import { type ReactNode } from "react";
3
+ import { type QueryPools } from "./query-pool.js";
3
4
  export type DispatcherProviderProps = {
4
5
  readonly dispatcher: Dispatcher;
5
6
  readonly children: ReactNode;
6
7
  };
7
8
  export declare function DispatcherProvider({ dispatcher, children }: DispatcherProviderProps): ReactNode;
9
+ export declare function useOptionalQueryPools(): QueryPools | undefined;
8
10
  export declare function useDispatcher(): Dispatcher;
9
11
  export declare function useOptionalDispatcher(): Dispatcher | undefined;
10
12
  export declare function useDispatcherStatus(): DispatcherStatus;
@@ -1,6 +1,7 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- import { createContext, useContext } from "react";
2
+ import { createContext, useContext, useState } from "react";
3
3
  import { useStore } from "../hooks/use-store.js";
4
+ import { createQueryPools } from "./query-pool.js";
4
5
  // React Context threading the Dispatcher through the tree. An app
5
6
  // wraps its root in <DispatcherProvider dispatcher={createLiveDispatcher()}>
6
7
  // and every hook below reaches into that context instead of taking the
@@ -11,8 +12,14 @@ import { useStore } from "../hooks/use-store.js";
11
12
  // dispatcher per app (rebuilding would wipe its in-flight tracking and
12
13
  // status listeners). Tests wire a fake dispatcher in directly.
13
14
  const DispatcherContext = createContext(null);
15
+ const QueryPoolsContext = createContext(null);
14
16
  export function DispatcherProvider({ dispatcher, children }) {
15
- return _jsx(DispatcherContext, { value: dispatcher, children: children });
17
+ // Pools live with the provider so every hook of one query name shares them.
18
+ const [queryPools] = useState(createQueryPools);
19
+ return (_jsx(DispatcherContext, { value: dispatcher, children: _jsx(QueryPoolsContext, { value: queryPools, children: children }) }));
20
+ }
21
+ export function useOptionalQueryPools() {
22
+ return useContext(QueryPoolsContext) ?? undefined;
16
23
  }
17
24
  // Reads the ambient Dispatcher. Throws instead of returning null when
18
25
  // no provider is mounted — a dispatcher-less hook is always a
@@ -0,0 +1,11 @@
1
+ export type QuerySlot = {
2
+ readonly release: () => void;
3
+ };
4
+ export type QueryPools = {
5
+ /** Resolves with a slot once fewer than `limit` requests of this query name
6
+ * run, or with null when `signal` aborts first (a waiter that gives up
7
+ * never holds a slot). The caller must release a granted slot exactly once. Hooks of one
8
+ * query name are expected to pass the same limit. */
9
+ readonly acquire: (name: string, limit: number, signal: AbortSignal) => Promise<QuerySlot | null>;
10
+ };
11
+ export declare function createQueryPools(): QueryPools;
@@ -0,0 +1,64 @@
1
+ export function createQueryPools() {
2
+ const pools = new Map();
3
+ const poolFor = (name) => {
4
+ const existing = pools.get(name);
5
+ if (existing)
6
+ return existing;
7
+ const created = { running: 0, waiting: [] };
8
+ pools.set(name, created);
9
+ return created;
10
+ };
11
+ const makeSlot = (name, pool) => {
12
+ let released = false;
13
+ return {
14
+ release: () => {
15
+ if (!released) {
16
+ released = true;
17
+ pool.running -= 1;
18
+ drain(name, pool);
19
+ }
20
+ },
21
+ };
22
+ };
23
+ const drain = (name, pool) => {
24
+ let next = pool.waiting[0];
25
+ while (next !== undefined && pool.running < next.limit) {
26
+ pool.waiting.shift();
27
+ pool.running += 1;
28
+ next.grant(makeSlot(name, pool));
29
+ next = pool.waiting[0];
30
+ }
31
+ if (pool.running === 0 && pool.waiting.length === 0)
32
+ pools.delete(name);
33
+ };
34
+ return {
35
+ acquire: (name, limit, signal) => {
36
+ if (signal.aborted)
37
+ return Promise.resolve(null);
38
+ const pool = poolFor(name);
39
+ if (pool.waiting.length === 0 && pool.running < limit) {
40
+ pool.running += 1;
41
+ return Promise.resolve(makeSlot(name, pool));
42
+ }
43
+ return new Promise((resolve) => {
44
+ const waiter = {
45
+ limit,
46
+ grant: (slot) => {
47
+ signal.removeEventListener("abort", onAbort);
48
+ resolve(slot);
49
+ },
50
+ };
51
+ const onAbort = () => {
52
+ const index = pool.waiting.indexOf(waiter);
53
+ if (index >= 0)
54
+ pool.waiting.splice(index, 1);
55
+ signal.removeEventListener("abort", onAbort);
56
+ resolve(null);
57
+ drain(name, pool);
58
+ };
59
+ signal.addEventListener("abort", onAbort);
60
+ pool.waiting.push(waiter);
61
+ });
62
+ },
63
+ };
64
+ }
@@ -9,6 +9,7 @@ export type UseQueryOptions = {
9
9
  readonly enabled?: boolean;
10
10
  readonly live?: boolean;
11
11
  readonly refetchIntervalMs?: number;
12
+ readonly concurrency?: number;
12
13
  };
13
14
  export declare function entityFromQueryType(type: string): string | undefined;
14
15
  export declare function useQuery<TData = unknown>(type: string, payload: unknown, options?: UseQueryOptions): UseQueryResult<TData>;
@@ -1,5 +1,5 @@
1
1
  import { useCallback, useEffect, useRef, useState } from "react";
2
- import { useDispatcher } from "../context/dispatcher-context.js";
2
+ import { useDispatcher, useOptionalQueryPools } from "../context/dispatcher-context.js";
3
3
  import { useLiveEvents } from "../sse/live-events.js";
4
4
  // Extract the entity-name from a standard Kumiko query type. Returns
5
5
  // undefined for non-conforming types so the live-mode silently skips
@@ -13,9 +13,13 @@ export function entityFromQueryType(type) {
13
13
  return undefined;
14
14
  return parts[2];
15
15
  }
16
+ function isValidConcurrency(value) {
17
+ return value !== undefined && Number.isFinite(value) && value >= 1;
18
+ }
16
19
  export function useQuery(type, payload, options = {}) {
17
20
  const dispatcher = useDispatcher();
18
- const { enabled = true, live = false, refetchIntervalMs } = options;
21
+ const { enabled = true, live = false, refetchIntervalMs, concurrency } = options;
22
+ const queryPools = useOptionalQueryPools();
19
23
  const [data, setData] = useState(null);
20
24
  const [error, setError] = useState(null);
21
25
  const [loading, setLoading] = useState(enabled);
@@ -45,10 +49,21 @@ export function useQuery(type, payload, options = {}) {
45
49
  if (!background)
46
50
  setLoading(true);
47
51
  let result;
52
+ let slot = null;
48
53
  try {
54
+ if (queryPools !== undefined && isValidConcurrency(concurrency)) {
55
+ slot = await queryPools.acquire(type, Math.floor(concurrency), ctrl.signal);
56
+ // skip: aborted while queued, the superseding run owns the state
57
+ if (slot === null)
58
+ return;
59
+ // skip: slot granted just as the run was superseded, finally frees it
60
+ if (ctrl.signal.aborted)
61
+ return;
62
+ }
49
63
  result = await dispatcher.query(type, payload, { signal: ctrl.signal });
50
64
  }
51
65
  finally {
66
+ slot?.release();
52
67
  // A rejected fetch must not leave polling stuck; a superseded one must
53
68
  // not clear the newer fetch's flag.
54
69
  if (activeCtrl.current === ctrl)
@@ -69,7 +84,7 @@ export function useQuery(type, payload, options = {}) {
69
84
  setError(result.error);
70
85
  }
71
86
  setLoading(false);
72
- }, [dispatcher, type, payloadKey]);
87
+ }, [dispatcher, type, payloadKey, queryPools, concurrency]);
73
88
  const run = useCallback(() => runFetch(false), [runFetch]);
74
89
  useEffect(() => {
75
90
  if (!enabled) {
@@ -1305,6 +1305,9 @@ export type PageHeaderProps = {
1305
1305
  readonly actions?: ReactNode;
1306
1306
  /** Land in the shell header's overflow menu at phone width. */
1307
1307
  readonly overflowItems?: readonly ActionMenuItemSpec[];
1308
+ /** Hide the shell header's breadcrumb while mounted, for a screen that stands in
1309
+ * for the one the route names (a locked screen's fallback). */
1310
+ readonly hideBreadcrumb?: boolean;
1308
1311
  };
1309
1312
  /** One item in an `ActionOverflowMenu` (A7: header/row actions beyond the
1310
1313
  * primary one collapse into this menu instead of a wall of buttons). */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer",
3
- "version": "0.346.0",
3
+ "version": "0.348.0",
4
4
  "description": "Platform-agnostic React renderer for Kumiko screens. Contains the shared logic — primitives-contract, hooks, KumikoScreen, navigation & SSE abstractions — that any platform-specific renderer (web, native) composes. No DOM, no EventSource, no react-dom.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -15,9 +15,9 @@
15
15
  }
16
16
  },
17
17
  "dependencies": {
18
- "@cosmicdrift/kumiko-framework": "0.346.0",
19
- "@cosmicdrift/kumiko-headless": "0.346.0",
20
- "@cosmicdrift/kumiko-types": "0.346.0",
18
+ "@cosmicdrift/kumiko-framework": "0.348.0",
19
+ "@cosmicdrift/kumiko-headless": "0.348.0",
20
+ "@cosmicdrift/kumiko-types": "0.348.0",
21
21
  "react": "^19.2.6",
22
22
  "temporal-polyfill": "^0.3.2",
23
23
  "zod": "^4.4.3"
@@ -28,7 +28,7 @@
28
28
  "@types/react-dom": "^19.2.3",
29
29
  "jsdom": "^29.1.1",
30
30
  "react-dom": "^19.2.6",
31
- "@cosmicdrift/kumiko-locale-de": "0.346.0"
31
+ "@cosmicdrift/kumiko-locale-de": "0.348.0"
32
32
  },
33
33
  "repository": {
34
34
  "type": "git",
package/src/changes.json CHANGED
@@ -1,4 +1,40 @@
1
1
  [
2
+ {
3
+ "version": "0.348.0",
4
+ "type": "improvement",
5
+ "title": "ModeSwitch gains a pill variant and className, LanguageSwitcher gains a compact chip variant",
6
+ "detail": "ModeSwitch pill variant and LanguageSwitcher chip variant\n`ModeSwitch` gets `variant=\"pill\"` (grey track, raised active segment, dark mode aware) and a `className` prop; `outline` stays the default. `LanguageSwitcher` gets `variant=\"chip\"`, a compact monospace chip with the uppercase locale code in a border; `default` stays as it was."
7
+ },
8
+ {
9
+ "version": "0.348.0",
10
+ "type": "improvement",
11
+ "title": "TimeseriesChart takes a height, an optional y-axis with gridlines and a date axis with n labels",
12
+ "detail": "TimeseriesChart: height, y-axis gridlines and a date axis\n`height` (px) replaces the fixed `h-16`; without it the chart looks as before. `yAxis: { ticks, format? }` draws that many gridlines with rounded value labels (0/200/400/600) in a left gutter, and the y-scale reaches the top tick. `xAxis: { ticks, format }` renders n evenly spaced date labels instead of the fixed start/mid/end of `axisLabels`."
13
+ },
14
+ {
15
+ "version": "0.348.0",
16
+ "type": "improvement",
17
+ "title": "useQuery accepts concurrency to cap parallel requests per query name through a pool in the DispatcherProvider",
18
+ "detail": "useQuery option `concurrency` caps parallel requests per query name\n`useQuery(type, payload, { concurrency: n })` lets at most n `dispatcher.query` calls of that query name run at once. The pool lives in the DispatcherProvider and is shared by every hook with the same name; the rest wait in line with `loading` still true. A hook that unmounts, refetches or changes its payload while waiting leaves the line without holding a slot, and a finished or failed request hands its slot to the next waiter. Without the option nothing changes."
19
+ },
20
+ {
21
+ "version": "0.347.0",
22
+ "type": "improvement",
23
+ "title": "actionForm successMessage names the chosen record's label after a successful submit",
24
+ "detail": "actionForm screens can confirm a submit with the chosen record\nA new `successMessage` i18n key on an `actionForm` screen shows a confirmation above the form after a successful submit, for screens that stay put (no `redirect`, not in a drawer). `{field}` placeholders take the submitted values, and a `reference` field shows the chosen record's label instead of its id. The tier-engine admin form uses it: \"Tier assigned: Acme → Pro\", so choosing the wrong tenant is visible right away. New i18n key: `tier-admin.success` (en/de/es). `RenderEdit`'s `onSubmit` receives the submitted values as a second argument."
25
+ },
26
+ {
27
+ "version": "0.347.0",
28
+ "type": "fix",
29
+ "title": "Switching between generated config screens no longer shows empty fields for stored values",
30
+ "detail": "Settings screens show their stored values after switching screens\nMoving from one generated settings screen to another kept the previous screen's form state, so the fields of the new screen rendered empty and a select showed no active option even though a tenant value was set. Each config and secrets screen now mounts its own form."
31
+ },
32
+ {
33
+ "version": "0.347.0",
34
+ "type": "fix",
35
+ "title": "The shell header drops the locked screen's breadcrumb while a visibleWhen fallback or notice is shown",
36
+ "detail": "A locked screen's fallback shows no breadcrumb\nWhen `visibleWhen` is unmet, the route still names the locked screen, so the shell header kept that screen's breadcrumb above the fallback or the unavailable notice. The gate now mounts `<PageHeader hideBreadcrumb />`, and the shell header drops the breadcrumb while it is mounted. Embedded screens (dashboard panels, drawers) are not affected."
37
+ },
2
38
  {
3
39
  "version": "0.343.0",
4
40
  "type": "improvement",