@cosmicdrift/kumiko-renderer-web 0.334.0 → 0.336.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.
@@ -5,6 +5,8 @@ export declare function isAppSchemaPayload(value: unknown): value is AppSchema;
5
5
  export type AppSchemaFetchResult = {
6
6
  readonly kind: "loaded";
7
7
  readonly app: AppSchema;
8
+ } | {
9
+ readonly kind: "session-ended";
8
10
  } | {
9
11
  readonly kind: "unauthorized";
10
12
  } | {
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- import { toAppSchema, usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
2
+ import { toAppSchema, usePrimitives, useSessionEndedSignal, useTranslation, } from "@cosmicdrift/kumiko-renderer";
3
3
  import { useEffect, useRef, useState } from "react";
4
4
  // Mirrors framework's api-constants Routes.schema, same pattern as
5
5
  // dispatcher-live's PATH_* constants — the one place that spells out the
@@ -40,7 +40,9 @@ export async function fetchAppSchema(signal) {
40
40
  throw err;
41
41
  return { kind: "failed" };
42
42
  }
43
- if (res.status === 401 || res.status === 403)
43
+ if (res.status === 401)
44
+ return { kind: "session-ended" };
45
+ if (res.status === 403)
44
46
  return { kind: "unauthorized" };
45
47
  if (!res.ok)
46
48
  return { kind: "failed" };
@@ -63,6 +65,7 @@ export function AppSchemaFetchBoot({ onLoaded, }) {
63
65
  const t = useTranslation();
64
66
  const { Banner, Button } = usePrimitives();
65
67
  const [status, setStatus] = useState("loading");
68
+ const sessionEndedSignal = useSessionEndedSignal();
66
69
  const [attempt, setAttempt] = useState(0);
67
70
  // Ref instead of an effect dependency: onLoaded is a fresh closure on
68
71
  // every KumikoAppRoot render (it captures setApp) — depending on it
@@ -89,13 +92,19 @@ export function AppSchemaFetchBoot({ onLoaded, }) {
89
92
  });
90
93
  return () => controller.abort();
91
94
  }, [attempt]);
95
+ useEffect(() => {
96
+ if (status === "session-ended")
97
+ sessionEndedSignal?.notify();
98
+ }, [status, sessionEndedSignal]);
92
99
  if (status === "loading") {
93
100
  return (_jsx("div", { role: "status", "aria-busy": "true", children: t("kumiko.app-boot.loading") }));
94
101
  }
95
- if (status === "unauthorized") {
102
+ if (status === "unauthorized" || status === "session-ended") {
96
103
  // No auto-retry/reload here on purpose — a signed-out user retrying
97
104
  // the same request just gets the same 401 again; the login gate that
98
- // wraps this component is the actual way out.
105
+ // wraps this component is the actual way out. For session-ended the
106
+ // notify above normally swaps this banner for the login screen; the
107
+ // banner stays as fallback when no session provider is mounted.
99
108
  return (_jsx(Banner, { variant: "error", padded: true, children: t("kumiko.app-boot.unauthorized") }));
100
109
  }
101
110
  return (_jsx(Banner, { variant: "error", padded: true, actions: _jsx(Button, { variant: "secondary", onClick: () => setAttempt((n) => n + 1), children: t("kumiko.actions.reload") }), children: t("kumiko.app-boot.failed") }));
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { createLiveDispatcher } from "@cosmicdrift/kumiko-dispatcher-live";
3
3
  import { isOpenToAllGranted } from "@cosmicdrift/kumiko-framework/ui-types";
4
- import { AppFeaturesProvider, ColumnRenderersProvider, ContentEditorsProvider, CustomScreensProvider, DashboardBodyProvider, DispatcherProvider, DraftStorageProvider, ExtensionSectionsProvider, hasDetailScreen, KumikoScreen, kumikoDefaultTranslations, LiveEventsProvider, LocaleProvider, mergeTranslations, NavProvider, navigateWithReturnTo, PrimitivesProvider, qualifyScreenId, TokensProvider, toAppSchema, translationsByLocaleFromKeys, useNav, usePrimitives, useTranslation, } from "@cosmicdrift/kumiko-renderer";
4
+ import { AppFeaturesProvider, ColumnRenderersProvider, ContentEditorsProvider, CustomScreensProvider, createSessionEndedSignal, DashboardBodyProvider, DispatcherProvider, DraftStorageProvider, ExtensionSectionsProvider, hasDetailScreen, KumikoScreen, kumikoDefaultTranslations, LiveEventsProvider, LocaleProvider, mergeTranslations, NavProvider, navigateWithReturnTo, PrimitivesProvider, qualifyScreenId, SessionEndedSignalProvider, TokensProvider, toAppSchema, translationsByLocaleFromKeys, useNav, usePrimitives, useTranslation, withSessionEndedDetection, } from "@cosmicdrift/kumiko-renderer";
5
5
  import { useEffect, useMemo, useRef, useState } from "react";
6
6
  import { createRoot } from "react-dom/client";
7
7
  import { lastSegment } from "../layout/nav-tree.js";
@@ -139,7 +139,7 @@ export function resolveRootScreenQn(features, explicitScreenQn, schemaIsRoleProj
139
139
  }
140
140
  if (!schemaIsRoleProjected)
141
141
  return explicitScreenQn;
142
- if (findOwnerFeatureInFeatures(features, explicitScreenQn) !== undefined)
142
+ if (findOwnerFeature(features, explicitScreenQn) !== undefined)
143
143
  return explicitScreenQn;
144
144
  return firstLandingScreenQnForProjectedSchema(features);
145
145
  }
@@ -233,7 +233,7 @@ function KumikoAppRoot(props) {
233
233
  warnMissingCustomScreens(loaded, props.customScreens);
234
234
  setApp(loaded);
235
235
  }, schemaIsRoleProjected: isFetchMode, ...(isFetchMode && { onUnmountResetSchema: () => setApp(undefined) }), ...(props.translate !== undefined && { translate: props.translate }), ...(props.onRowClick !== undefined && { onRowClick: props.onRowClick }), ...(props.shell !== undefined && { shell: props.shell }) }));
236
- return (_jsx(TokensBoot, { children: _jsxs(LocaleProvider, { resolver: props.localeResolver, fallbackBundles: fallbackBundles, children: [_jsx(DocumentLangSync, { resolver: props.localeResolver }), _jsx(PrimitivesProvider, { value: props.primitives, children: _jsx(ScreenWidthProvider, { width: props.screenWidth ?? "4xl", children: _jsx(AppFeaturesProvider, { features: app?.features ?? EMPTY_FEATURES, children: _jsx(DispatcherProvider, { dispatcher: props.dispatcher, children: _jsx(DraftStorageProvider, { value: props.draftStorage, children: _jsx(LiveEventsProvider, { value: props.liveEvents, children: _jsx(DashboardBodyProvider, { value: WebDashboardBody, children: _jsx(CustomScreensProvider, { value: props.customScreens, children: _jsx(ColumnRenderersProvider, { value: props.columnRenderers, children: _jsx(ContentEditorsProvider, { value: props.contentEditors, children: _jsx(ExtensionSectionsProvider, { value: props.extensionSectionComponents, children: _jsx(NavProvidersProvider, { value: navProviders, entities: navEntities, children: _jsx(ResolversProvider, { resolvers: props.resolvers, children: _jsxs(ToastProvider, { children: [_jsx(UpdateChecker, {}), stackWrappers(props.providers, stackWrappers(props.gates, screenNode))] }) }) }) }) }) }) }) }) }) }) }) }) }) })] }) }));
236
+ return (_jsx(TokensBoot, { children: _jsxs(LocaleProvider, { resolver: props.localeResolver, fallbackBundles: fallbackBundles, children: [_jsx(DocumentLangSync, { resolver: props.localeResolver }), _jsx(PrimitivesProvider, { value: props.primitives, children: _jsx(ScreenWidthProvider, { width: props.screenWidth ?? "4xl", children: _jsx(AppFeaturesProvider, { features: app?.features ?? EMPTY_FEATURES, children: _jsx(DispatcherProvider, { dispatcher: props.dispatcher, children: _jsx(DraftStorageProvider, { value: props.draftStorage, children: _jsx(LiveEventsProvider, { value: props.liveEvents, children: _jsx(DashboardBodyProvider, { value: WebDashboardBody, children: _jsx(CustomScreensProvider, { value: props.customScreens, children: _jsx(ColumnRenderersProvider, { value: props.columnRenderers, children: _jsx(ContentEditorsProvider, { value: props.contentEditors, children: _jsx(ExtensionSectionsProvider, { value: props.extensionSectionComponents, children: _jsx(NavProvidersProvider, { value: navProviders, entities: navEntities, children: _jsx(ResolversProvider, { resolvers: props.resolvers, children: _jsxs(ToastProvider, { children: [_jsx(UpdateChecker, {}), _jsx(SessionEndedSignalProvider, { signal: props.sessionEndedSignal, children: stackWrappers(props.providers, stackWrappers(props.gates, screenNode)) })] }) }) }) }) }) }) }) }) }) }) }) }) }) })] }) }));
237
237
  }
238
238
  // Sits INSIDE the clientFeature gates (e.g. an auth gate) — a gate that
239
239
  // renders a login placeholder instead of children keeps this component
@@ -284,7 +284,8 @@ export function createKumikoApp(options = {}) {
284
284
  throw new Error("createKumikoApp: schema contains no screens accessible without a role restriction. Add at least one entry to `schema.screens` without `access.roles`, or pass `screenQn` explicitly.");
285
285
  }
286
286
  }
287
- const dispatcher = options.dispatcher ?? createLiveDispatcher();
287
+ const sessionEndedSignal = createSessionEndedSignal();
288
+ const dispatcher = withSessionEndedDetection(options.dispatcher ?? createLiveDispatcher(), sessionEndedSignal);
288
289
  const draftStorage = options.draftStorage ?? createBrowserDraftStorage();
289
290
  const primitives = { ...defaultPrimitives, ...(options.primitives ?? {}) };
290
291
  const liveEvents = createEventSourceLiveEvents();
@@ -331,7 +332,7 @@ export function createKumikoApp(options = {}) {
331
332
  const localeResolver = options.locale ?? createBrowserLocaleResolver();
332
333
  const navAdapter = options.navAdapter ?? useBrowserNavApi;
333
334
  const root = createRoot(container);
334
- root.render(_jsx(KumikoAppRoot, { initialApp: initialApp, screenQn: options.screenQn, clientFeatures: clientFeatures, providers: providers, gates: gates, customScreens: customScreens, columnRenderers: columnRenderers, extensionSectionComponents: extensionSectionComponents, contentEditors: contentEditors, resolvers: resolvers, dispatcher: dispatcher, draftStorage: draftStorage, primitives: primitives, liveEvents: liveEvents, localeResolver: localeResolver, navAdapter: navAdapter, screenWidth: options.screenWidth, ...(options.translate !== undefined && { translate: options.translate }), ...(options.onRowClick !== undefined && { onRowClick: options.onRowClick }), ...(options.shell !== undefined && { shell: options.shell }) }));
335
+ root.render(_jsx(KumikoAppRoot, { initialApp: initialApp, screenQn: options.screenQn, clientFeatures: clientFeatures, providers: providers, gates: gates, customScreens: customScreens, columnRenderers: columnRenderers, extensionSectionComponents: extensionSectionComponents, contentEditors: contentEditors, resolvers: resolvers, dispatcher: dispatcher, sessionEndedSignal: sessionEndedSignal, draftStorage: draftStorage, primitives: primitives, liveEvents: liveEvents, localeResolver: localeResolver, navAdapter: navAdapter, screenWidth: options.screenWidth, ...(options.translate !== undefined && { translate: options.translate }), ...(options.onRowClick !== undefined && { onRowClick: options.onRowClick }), ...(options.shell !== undefined && { shell: options.shell }) }));
335
336
  return { root };
336
337
  }
337
338
  // TokensBoot uses the browser-backed TokensApi hook (class-based
@@ -348,9 +349,10 @@ function BrowserNavBoot({ app, fallbackQn, useNavApi, hasWorkspaces, translate,
348
349
  const screen = (_jsx(RoutedScreen, { app: app, fallbackQn: fallbackQn, ...(translate !== undefined && { translate }), ...(onRowClick !== undefined && { onRowClick }) }));
349
350
  return (_jsx(NavProvider, { value: navApi, children: Shell !== undefined ? _jsx(Shell, { schema: app, children: screen }) : screen }));
350
351
  }
351
- // Finds the feature that owns a fully qualified ScreenQn among a feature list.
352
- // Returns undefined if the screen isn't declared in any of them.
353
- function findOwnerFeatureInFeatures(features, qn) {
352
+ // Finds the feature that owns a fully qualified ScreenQn.
353
+ // Returns undefined if the screen isn't declared in any feature schema —
354
+ // KumikoScreen then renders the "Screen not found" banner.
355
+ function findOwnerFeature(features, qn) {
354
356
  for (const feature of features) {
355
357
  for (const s of feature.screens) {
356
358
  if (qualifyScreenId(feature.featureName, s.id) === qn)
@@ -359,11 +361,16 @@ function findOwnerFeatureInFeatures(features, qn) {
359
361
  }
360
362
  return undefined;
361
363
  }
362
- // Finds the feature that owns a fully qualified ScreenQn.
363
- // Returns undefined if the screen isn't declared in any feature schema —
364
- // KumikoScreen then renders the "Screen not found" banner.
365
- function findOwnerFeature(app, qn) {
366
- return findOwnerFeatureInFeatures(app.features, qn);
364
+ // Single source for "which entityEdit screen does a row click land on", shared
365
+ // by the click handler and hasRowClickTarget so a row never looks clickable
366
+ // without a target (or the reverse).
367
+ function findEditScreenForEntity(features, entityName) {
368
+ for (const feature of features) {
369
+ const editScreen = feature.screens.find((s) => s.type === "entityEdit" && s.entity === entityName);
370
+ if (editScreen)
371
+ return editScreen;
372
+ }
373
+ return undefined;
367
374
  }
368
375
  // fw#2640: a list screen with no reachable click target (no `detailFor`
369
376
  // screen for its entity, no app-wide onRowClick, no entityEdit screen)
@@ -382,7 +389,7 @@ function hasRowClickTarget(app, screen, onRowClick) {
382
389
  return false;
383
390
  if (hasDetailScreen(app.features, screen.entity))
384
391
  return true;
385
- return app.features.some((f) => f.screens.some((s) => s.type === "entityEdit" && s.entity === screen.entity));
392
+ return findEditScreenForEntity(app.features, screen.entity) !== undefined;
386
393
  }
387
394
  function RoutedScreen({ app, fallbackQn, translate, onRowClick, }) {
388
395
  const nav = useNav();
@@ -393,7 +400,7 @@ function RoutedScreen({ app, fallbackQn, translate, onRowClick, }) {
393
400
  // fallback feature (the one from fallbackQn).
394
401
  const { feature, qn, entityId, activeScreen } = useMemo(() => {
395
402
  if (nav.route === undefined) {
396
- const ownerFeature = findOwnerFeature(app, fallbackQn);
403
+ const ownerFeature = findOwnerFeature(app.features, fallbackQn);
397
404
  return {
398
405
  feature: ownerFeature,
399
406
  qn: fallbackQn,
@@ -410,7 +417,7 @@ function RoutedScreen({ app, fallbackQn, translate, onRowClick, }) {
410
417
  break;
411
418
  }
412
419
  }
413
- const ownerFeature = matchedFeature ?? findOwnerFeature(app, fallbackQn);
420
+ const ownerFeature = matchedFeature ?? findOwnerFeature(app.features, fallbackQn);
414
421
  const qualifiedQn = ownerFeature ? qualifyScreenId(ownerFeature.featureName, shortId) : shortId;
415
422
  return {
416
423
  feature: ownerFeature,
@@ -445,14 +452,11 @@ function RoutedScreen({ app, fallbackQn, translate, onRowClick, }) {
445
452
  // single-feature setup that's the same feature as the active one, in
446
453
  // multi-feature the edit could theoretically live in a different
447
454
  // feature (one that shares the entity).
448
- for (const f of app.features) {
449
- const editScreen = f.screens.find((s) => s.type === "entityEdit" && s.entity === entityName);
450
- if (editScreen) {
451
- // editScreen.id is already short form; lastSegment is a no-op
452
- // safety net here, kept for symmetry with the other call sites.
453
- navigateWithReturnTo(nav, { screenId: lastSegment(editScreen.id), entityId: row.id }, host);
454
- return;
455
- }
455
+ const editScreen = findEditScreenForEntity(app.features, entityName);
456
+ if (editScreen) {
457
+ // editScreen.id is already short form; lastSegment is a no-op
458
+ // safety net here, kept for symmetry with the other call sites.
459
+ navigateWithReturnTo(nav, { screenId: lastSegment(editScreen.id), entityId: row.id }, host);
456
460
  }
457
461
  };
458
462
  }, [app, activeScreen, onRowClick, nav, qn, entityId]);
@@ -1,7 +1,7 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { normalizeListColumn } from "@cosmicdrift/kumiko-framework/ui-types";
3
3
  import { dispatcherErrorText, EmbeddedScreenProvider, extensionSectionName, KumikoScreen, useEmbeddedScreen, useExtensionSectionComponent, useLocale, useNav, usePageHeaderSlotAvailable, usePrimitives, useQuery, useTranslation, } from "@cosmicdrift/kumiko-renderer";
4
- import { useEffect, useState } from "react";
4
+ import { createContext, useContext, useEffect, useMemo, useState } from "react";
5
5
  import { EmbeddedFormProvider } from "../primitives/index.js";
6
6
  import { PageSection } from "../primitives/layout.js";
7
7
  import { Skeleton } from "../ui/skeleton.js";
@@ -221,7 +221,7 @@ function ChartPanelBody({ panel, label, screenParams, translate, }) {
221
221
  } }));
222
222
  }
223
223
  function ListPanelBody({ panel, label, screenParams, translate, }) {
224
- const { formatPercent, formatNumber } = useDashboardFormats();
224
+ const { formatPercent, formatNumber, formatDateTime } = useDashboardFormats();
225
225
  const columns = panel.columns.map((c) => {
226
226
  const normalized = normalizeListColumn(c);
227
227
  return {
@@ -233,7 +233,7 @@ function ListPanelBody({ panel, label, screenParams, translate, }) {
233
233
  }),
234
234
  };
235
235
  });
236
- return (_jsx(QueryPanel, { panel: panel, label: label, screenParams: screenParams, translate: translate, skeleton: "rows", isEmpty: (data) => (data.rows ?? []).length === 0, children: (data) => (_jsx(DashboardListTable, { columns: columns, rows: data.rows ?? [], formatPercent: formatPercent, formatNumber: formatNumber, translate: translate })) }));
236
+ return (_jsx(QueryPanel, { panel: panel, label: label, screenParams: screenParams, translate: translate, skeleton: "rows", isEmpty: (data) => (data.rows ?? []).length === 0, children: (data) => (_jsx(DashboardListTable, { columns: columns, rows: data.rows ?? [], formatPercent: formatPercent, formatNumber: formatNumber, formatDateTime: formatDateTime, translate: translate })) }));
237
237
  }
238
238
  function FeedPanelBody({ panel, label, screenParams, translate, }) {
239
239
  return (_jsx(QueryPanel, { panel: panel, label: label, screenParams: screenParams, translate: translate, skeleton: "rows", isEmpty: (data) => (data.rows ?? []).length === 0, children: (data) => {
@@ -302,6 +302,7 @@ function useFilterParams(screen) {
302
302
  function useTimeRange(screen, translate) {
303
303
  const t = useTranslation();
304
304
  const nav = useNav();
305
+ const resolver = useLocale();
305
306
  const timeRange = screen.timeRange;
306
307
  if (timeRange === undefined)
307
308
  return { params: {}, control: null };
@@ -310,7 +311,9 @@ function useTimeRange(screen, translate) {
310
311
  ? fromUrl
311
312
  : timeRange.default;
312
313
  return {
313
- params: { [timeRange.id]: value },
314
+ // Metric buckets are cut server-side; without the user's zone they default
315
+ // to UTC while the axis labels are formatted in the user's zone.
316
+ params: { [timeRange.id]: value, timeZone: resolver.timeZone() },
314
317
  control: (_jsx(ModeSwitch, { value: value, options: timeRange.options.map((o) => ({ value: o.value, label: translate(o.label) })), onChange: (next) => nav.setSearchParams({ [timeRange.id]: next }), ariaLabel: t("kumiko.dashboard.time-range"), testId: `dashboard-time-range-${timeRange.id}` })),
315
318
  };
316
319
  }
@@ -345,14 +348,45 @@ function PanelBody({ panel, label, screenId, screenParams, translate, }) {
345
348
  }
346
349
  return (_jsx(ProgressListPanelBody, { panel: panel, label: text, screenParams: screenParams, translate: translate }));
347
350
  }
351
+ const VisibilityQueryContext = createContext(new Map());
352
+ // One live query per distinct `visibleWhen.query`, shared by every panel that
353
+ // gates on it — N panels on the same status query would otherwise each open
354
+ // their own request, SSE subscription and refetch per event.
355
+ function VisibilityQueryScope({ query, children, }) {
356
+ const parent = useContext(VisibilityQueryContext);
357
+ const result = useQuery(query, {}, { live: true });
358
+ const value = useMemo(() => new Map(parent).set(query, result), [parent, query, result]);
359
+ return (_jsx(VisibilityQueryContext.Provider, { value: value, children: children }));
360
+ }
361
+ function VisibilityQueryScopes({ queries, children, }) {
362
+ const [first, ...rest] = queries;
363
+ if (first === undefined)
364
+ return children;
365
+ return (_jsx(VisibilityQueryScope, { query: first, children: _jsx(VisibilityQueryScopes, { queries: rest, children: children }) }));
366
+ }
367
+ function distinctVisibilityQueries(panels) {
368
+ const queries = new Set();
369
+ for (const panel of panels) {
370
+ if (panel.kind === "screen" && panel.visibleWhen !== undefined) {
371
+ queries.add(panel.visibleWhen.query);
372
+ }
373
+ }
374
+ return [...queries];
375
+ }
348
376
  function ScreenPanelTile({ panel, featureName, translate, }) {
349
377
  const visibleWhen = panel.visibleWhen;
350
- const visibility = useQuery(visibleWhen?.query ?? "", {}, { enabled: visibleWhen !== undefined, live: true });
378
+ const visibility = useContext(VisibilityQueryContext).get(visibleWhen?.query ?? "");
351
379
  const target = useEmbeddedScreen(featureName, panel.screen);
352
380
  if (target === undefined)
353
381
  return null;
354
- if (visibleWhen !== undefined && visibility.data?.[visibleWhen.field] !== visibleWhen.eq) {
355
- return null;
382
+ if (visibleWhen !== undefined) {
383
+ // A failed gate query must not silently drop the panel: on the
384
+ // account-security page that would hide every MFA enable/disable path.
385
+ if (visibility?.error) {
386
+ return (_jsx("div", { className: WIDE_PANEL, "data-testid": `dashboard-panel-${panel.id}`, children: _jsx(PanelError, { label: panel.label !== undefined ? translate(panel.label) : panel.id, error: visibility.error, onRetry: () => void visibility.refetch() }) }));
387
+ }
388
+ if (visibility?.data?.[visibleWhen.field] !== visibleWhen.eq)
389
+ return null;
356
390
  }
357
391
  const screenBody = _jsx(KumikoScreen, { schema: target.schema, qn: target.qn, translate: translate });
358
392
  const embedded = panel.chromeless === true ? (_jsx(EmbeddedFormProvider, { children: _jsx(EmbeddedScreenProvider, { children: screenBody }) })) : (screenBody);
@@ -377,6 +411,7 @@ export function WebDashboardBody({ featureName, screen, translate, }) {
377
411
  const { params: rangeParams, control: rangeControl } = useTimeRange(screen, effectiveTranslate);
378
412
  const screenParams = { filterParams, rangeParams };
379
413
  const loadedAtMs = useLoadedAt(screenParams);
414
+ const visibilityQueries = distinctVisibilityQueries(screen.panels);
380
415
  const scope = screen.scope;
381
416
  // A raw description is agent-facing prose; only an i18n key is user-facing copy.
382
417
  const translatedDescription = screen.description !== undefined ? effectiveTranslate(screen.description) : undefined;
@@ -384,13 +419,13 @@ export function WebDashboardBody({ featureName, screen, translate, }) {
384
419
  const scopeBadge = scope !== undefined ? (_jsx(StatusBadge, { tone: "accent", testId: `dashboard-${screen.id}-scope`, children: effectiveTranslate(scope.badge) })) : null;
385
420
  // The shell header owns the title; the badge sits right after it there.
386
421
  const badgeInHeader = scopeBadge !== null && headerSlotAvailable && PageHeader !== undefined;
387
- return (_jsxs(PageSection, { className: "flex flex-col gap-4", testId: `dashboard-${screen.id}`, children: [badgeInHeader && _jsx(PageHeader, { status: scopeBadge }), _jsxs("div", { className: "flex flex-wrap items-end justify-between gap-3", children: [_jsxs("div", { className: "flex items-center gap-2", children: [!badgeInHeader && scopeBadge, picker] }), _jsxs("div", { className: "flex flex-wrap items-center gap-3", children: [screen.showUpdatedAt !== false && (_jsx("span", { "data-testid": `dashboard-${screen.id}-updated-at`, className: "text-xs tabular-nums text-muted-foreground", children: t("kumiko.dashboard.updated-at", { time: formatDateTime(loadedAtMs) }) })), rangeControl] })] }), description !== undefined && (_jsx(Text, { variant: "muted", testId: `dashboard-${screen.id}-description`, children: description })), scope?.notice !== undefined && (_jsx(Banner, { variant: "info", testId: `dashboard-${screen.id}-scope-notice`, children: effectiveTranslate(scope.notice) })), _jsx("div", { className: "grid gap-4 sm:grid-cols-2 lg:grid-cols-4", children: screen.panels.map((panel) => {
388
- if (panel.kind === "screen") {
389
- return (_jsx(ScreenPanelTile, { panel: panel, featureName: featureName, translate: effectiveTranslate }, panel.id));
390
- }
391
- const label = panel.kind === "custom" || panel.label === undefined
392
- ? undefined
393
- : effectiveTranslate(panel.label);
394
- return (_jsx("div", { className: panelSpanClassName(panel), children: _jsx(PanelBody, { panel: panel, label: label, screenId: screen.id, screenParams: screenParams, translate: effectiveTranslate }) }, panel.id));
395
- }) })] }));
422
+ return (_jsxs(PageSection, { className: "flex flex-col gap-4", testId: `dashboard-${screen.id}`, children: [badgeInHeader && _jsx(PageHeader, { status: scopeBadge }), _jsxs("div", { className: "flex flex-wrap items-end justify-between gap-3", children: [_jsxs("div", { className: "flex items-center gap-2", children: [!badgeInHeader && scopeBadge, picker] }), _jsxs("div", { className: "flex flex-wrap items-center gap-3", children: [screen.showUpdatedAt !== false && (_jsx("span", { "data-testid": `dashboard-${screen.id}-updated-at`, className: "text-xs tabular-nums text-muted-foreground", children: t("kumiko.dashboard.updated-at", { time: formatDateTime(loadedAtMs) }) })), rangeControl] })] }), description !== undefined && (_jsx(Text, { variant: "muted", testId: `dashboard-${screen.id}-description`, children: description })), scope?.notice !== undefined && (_jsx(Banner, { variant: "info", testId: `dashboard-${screen.id}-scope-notice`, children: effectiveTranslate(scope.notice) })), _jsx(VisibilityQueryScopes, { queries: visibilityQueries, children: _jsx("div", { className: "grid gap-4 sm:grid-cols-2 lg:grid-cols-4", children: screen.panels.map((panel) => {
423
+ if (panel.kind === "screen") {
424
+ return (_jsx(ScreenPanelTile, { panel: panel, featureName: featureName, translate: effectiveTranslate }, panel.id));
425
+ }
426
+ const label = panel.kind === "custom" || panel.label === undefined
427
+ ? undefined
428
+ : effectiveTranslate(panel.label);
429
+ return (_jsx("div", { className: panelSpanClassName(panel), children: _jsx(PanelBody, { panel: panel, label: label, screenId: screen.id, screenParams: screenParams, translate: effectiveTranslate }) }, panel.id));
430
+ }) }) })] }));
396
431
  }
@@ -298,7 +298,7 @@ function ActionGlyph({ icon }) {
298
298
  }
299
299
  // Boot validates screen XOR target for NavDefinition actions, but a
300
300
  // provider-emitted TreeNode reaches here without going through boot — warn
301
- // once per label instead of rendering a dead button (fw#2751 review).
301
+ // once per label instead of rendering a dead button.
302
302
  const warnedTreeActionLabels = new Set();
303
303
  function warnTreeActionDropped(label) {
304
304
  // skip: already warned for this label — suppresses the repeat, not the warning itself.
@@ -55,8 +55,7 @@ export function ShellHeader({ schema, user, headerActions, }) {
55
55
  e.preventDefault();
56
56
  nav.navigate({ screenId });
57
57
  }, children: crumb.label })) : isLast ? (_jsx("h1", { "aria-current": "page", className: "truncate text-lg font-semibold text-foreground", children: titleOverride ?? crumb.label })) : (_jsx(BreadcrumbPage, { className: "whitespace-nowrap text-foreground-secondary", children: crumb.label })) })] }, screenId ?? crumb.label));
58
- }) }) })), _jsx("div", { ref: setStatusElement, "data-kumiko-layout": "page-header-status", className: cn("flex items-center gap-2 empty:hidden", compact ? "min-w-0 overflow-hidden" : "shrink-0") })] }), _jsxs("div", { className: cn("ml-auto flex items-center gap-2", compact ? "shrink-0" : "min-w-0 max-w-[50%] shrink-0 sm:max-w-none"), children: [_jsx("div", { ref: setActionsElement, "data-kumiko-layout": "page-header-actions", className: cn("flex items-center gap-2 empty:hidden", !compact &&
59
- "min-w-0 [&_*]:min-w-0 [&_*]:max-w-full [&_button]:overflow-hidden [&_button]:whitespace-nowrap") }), !compact && headerActions !== undefined && (_jsx("div", { "data-kumiko-layout": "header-actions", className: "flex items-center gap-2", children: headerActions })), hasOverflow && _jsx(HeaderOverflow, { items: overflowItems, headerActions: headerActions })] })] }));
58
+ }) }) })), _jsx("div", { ref: setStatusElement, "data-kumiko-layout": "page-header-status", className: cn("flex items-center gap-2 empty:hidden", compact ? "min-w-0 overflow-hidden" : "shrink-0") })] }), _jsxs("div", { className: cn("ml-auto flex items-center gap-2", compact ? "min-w-0 max-w-[60%]" : "min-w-0 max-w-[50%] shrink-0 sm:max-w-none"), children: [_jsx("div", { ref: setActionsElement, "data-kumiko-layout": "page-header-actions", className: cn("flex min-w-0 items-center gap-2 empty:hidden [&_*]:min-w-0 [&_*]:max-w-full [&_button]:overflow-hidden [&_button]:whitespace-nowrap", compact && "overflow-hidden") }), !compact && headerActions !== undefined && (_jsx("div", { "data-kumiko-layout": "header-actions", className: "flex items-center gap-2", children: headerActions })), hasOverflow && _jsx(HeaderOverflow, { items: overflowItems, headerActions: headerActions })] })] }));
60
59
  }
61
60
  // A hand-rolled menu instead of Radix DropdownMenu: app headerActions are opaque
62
61
  // nodes with their own popovers, and the panel stays mounted while closed so their
@@ -94,8 +93,15 @@ function HeaderOverflow({ items, headerActions, }) {
94
93
  };
95
94
  }, [open]);
96
95
  const onPanelKeyDown = (event) => {
96
+ // Opaque app headerActions (inputs, plain buttons) keep native Tab/arrow behaviour.
97
+ if (!(event.target instanceof HTMLElement) ||
98
+ event.target.getAttribute("role") !== "menuitem") {
99
+ return;
100
+ }
97
101
  if (event.key === "Tab") {
102
+ event.preventDefault();
98
103
  setOpen(false);
104
+ triggerRef.current?.focus();
99
105
  return;
100
106
  }
101
107
  if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key))
@@ -103,7 +109,7 @@ function HeaderOverflow({ items, headerActions, }) {
103
109
  const menuItems = Array.from(panelRef.current?.querySelectorAll(MENU_ITEM_SELECTOR) ?? []);
104
110
  if (menuItems.length === 0)
105
111
  return;
106
- const current = menuItems.findIndex((item) => item === document.activeElement);
112
+ const current = menuItems.indexOf(document.activeElement);
107
113
  const last = menuItems.length - 1;
108
114
  const next = event.key === "Home"
109
115
  ? 0
@@ -0,0 +1,9 @@
1
+ export type ThemeToggleLabelOptions = {
2
+ readonly titleInDark?: string | undefined;
3
+ readonly titleInLight?: string | undefined;
4
+ };
5
+ export declare function useThemeToggleLabel({ titleInDark, titleInLight }: ThemeToggleLabelOptions): {
6
+ readonly isDark: boolean;
7
+ readonly title: string;
8
+ readonly toggleMode: () => void;
9
+ };
@@ -0,0 +1,11 @@
1
+ import { useOptionalTranslation, useTokenController } from "@cosmicdrift/kumiko-renderer";
2
+ export function useThemeToggleLabel({ titleInDark, titleInLight }) {
3
+ const { mode, toggleMode } = useTokenController();
4
+ // Optional: the toggle also renders outside a LocaleProvider (samples, tests).
5
+ const translate = useOptionalTranslation();
6
+ const isDark = mode === "dark";
7
+ const title = isDark
8
+ ? (titleInDark ?? translate?.("kumiko.theme.light") ?? "Light theme")
9
+ : (titleInLight ?? translate?.("kumiko.theme.dark") ?? "Dark theme");
10
+ return { isDark, title, toggleMode };
11
+ }
@@ -1,14 +1,9 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useOptionalTranslation, useTokenController } from "@cosmicdrift/kumiko-renderer";
3
2
  import { DropdownMenuItem } from "../primitives/dropdown-menu.js";
3
+ import { useThemeToggleLabel } from "./theme-label.js";
4
4
  /** Theme toggle as a dropdown entry, for a user menu that carries the shell controls. */
5
5
  export function ThemeMenuItem({ lightIcon = "☀", darkIcon = "☾", titleInDark, titleInLight, testId, }) {
6
- const { mode, toggleMode } = useTokenController();
7
- const isDark = mode === "dark";
8
- const translate = useOptionalTranslation();
9
- const title = isDark
10
- ? (titleInDark ?? translate?.("kumiko.theme.light") ?? "Light theme")
11
- : (titleInLight ?? translate?.("kumiko.theme.dark") ?? "Dark theme");
6
+ const { isDark, title, toggleMode } = useThemeToggleLabel({ titleInDark, titleInLight });
12
7
  return (_jsxs(DropdownMenuItem, { "data-testid": testId, onSelect: (event) => {
13
8
  event.preventDefault();
14
9
  toggleMode();
@@ -4,18 +4,12 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
4
4
  // Dependency zieht. Default: Unicode-Glyphs (☀ / ☾) — funktionieren in
5
5
  // jedem Browser, jede App kann lucide/heroicons/eigene SVG via Props
6
6
  // reinreichen.
7
- import { useOptionalTranslation, useTokenController } from "@cosmicdrift/kumiko-renderer";
8
7
  import { useContext } from "react";
9
8
  import { HeaderOverflowMenuContext, headerOverflowMenuItemClass } from "./header-overflow-menu.js";
9
+ import { useThemeToggleLabel } from "./theme-label.js";
10
10
  export function ThemeToggle({ lightIcon = "☀", darkIcon = "☾", titleInDark, titleInLight, testId, }) {
11
- const { mode, toggleMode } = useTokenController();
12
11
  const inOverflowMenu = useContext(HeaderOverflowMenuContext);
13
- // Optional: the toggle also renders outside a LocaleProvider (samples, tests).
14
- const translate = useOptionalTranslation();
15
- const isDark = mode === "dark";
16
- const title = isDark
17
- ? (titleInDark ?? translate?.("kumiko.theme.light") ?? "Light theme")
18
- : (titleInLight ?? translate?.("kumiko.theme.dark") ?? "Dark theme");
12
+ const { isDark, title, toggleMode } = useThemeToggleLabel({ titleInDark, titleInLight });
19
13
  const icon = isDark ? lightIcon : darkIcon;
20
14
  if (inOverflowMenu) {
21
15
  return (_jsxs("button", { type: "button", role: "menuitem", onClick: toggleMode, className: headerOverflowMenuItemClass, "data-testid": testId, children: [_jsx("span", { "aria-hidden": "true", children: icon }), _jsx("span", { children: title })] }));
@@ -105,7 +105,7 @@ export function ComboboxInput(props) {
105
105
  const isSelected = multiple
106
106
  ? selectedValues.has(opt.value)
107
107
  : opt.value === singleValue;
108
- return (_jsxs(Command.Item, { value: opt.label, keywords: opt.description !== undefined ? [opt.description] : undefined, onSelect: () => {
108
+ return (_jsxs(Command.Item, { "data-testid": `combobox-${id}-option-${opt.value}`, value: opt.label, keywords: opt.description !== undefined ? [opt.description] : undefined, onSelect: () => {
109
109
  if (props.multiple === true) {
110
110
  toggleMulti(opt.value);
111
111
  }
@@ -276,7 +276,7 @@ export function EmbeddedListInput({ id, columns, rows, totals, currency, disable
276
276
  return (_jsxs("div", { ref: containerRef, "data-testid": testId !== undefined ? `${testId}-empty` : "embedded-list-empty", className: "flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed p-10 text-center text-sm text-muted-foreground", children: [_jsx("span", { children: emptyLabel }), showControls && (_jsxs(UiButton, { type: "button", variant: "outline", size: "sm", onClick: onAddRow, "data-testid": testId !== undefined ? `${testId}-empty-add` : "embedded-list-empty-add", children: [_jsx(Plus, { className: "size-4", "aria-hidden": "true" }), emptyCtaLabel] }))] }));
277
277
  }
278
278
  const hasTotals = totals !== undefined && totals.length > 0;
279
- return (_jsxs("div", { ref: containerRef, "data-testid": testId, className: "min-w-0", children: [!isMobile && (_jsxs("div", { "data-testid": testIdFor("desktop"), className: "hidden min-w-0 md:block", children: [_jsxs("div", { className: "min-w-0 overflow-hidden rounded-lg border bg-card", children: [_jsxs("div", { className: "relative min-w-0", children: [_jsx("div", { ref: setScrollContainerEl, "data-testid": testIdFor("desktop-scroll"), className: "overflow-x-auto [&_[data-slot=table-container]]:overflow-x-visible", children: _jsxs(Table, { className: "w-full table-fixed", style: { minWidth: `${tableMinWidthRem(columns, showControls)}rem` }, children: [_jsxs("colgroup", { children: [columns.map((column) => (_jsx("col", { style: columnWidthStyle(column.type) }, column.field))), showControls && _jsx("col", { style: { width: `${ACTIONS_COLUMN_REM}rem` } })] }), _jsx(TableHeader, { className: "bg-muted", children: _jsxs(TableRow, { className: "hover:bg-transparent", children: [columns.map((column) => (_jsx(TableHead, { className: columnAlignClass(column.type), children: column.label }, column.field))), showControls && (_jsx(TableHead, { className: cn("sticky right-0 z-10 whitespace-nowrap bg-muted px-1 text-right", isHorizontallyScrollable &&
279
+ return (_jsxs("div", { ref: containerRef, "data-testid": testId, className: "min-w-0", children: [!isMobile && (_jsxs("div", { "data-testid": testIdFor("desktop"), className: "hidden min-w-0 md:block", children: [_jsxs("div", { className: "min-w-0 overflow-hidden rounded-lg border bg-card", children: [_jsxs("div", { className: "relative min-w-0", children: [_jsx("div", { ref: setScrollContainerEl, "data-testid": testIdFor("desktop-scroll"), className: "overflow-x-auto [&_[data-slot=table-container]]:overflow-x-visible", children: _jsxs(Table, { className: "w-full table-fixed", style: { minWidth: `${tableMinWidthRem(columns, showControls)}rem` }, children: [_jsxs("colgroup", { children: [columns.map((column) => (_jsx("col", { style: columnWidthStyle(column.type) }, column.field))), showControls && _jsx("col", { style: { width: `${ACTIONS_COLUMN_REM}rem` } })] }), _jsx(TableHeader, { className: "bg-muted", children: _jsxs(TableRow, { className: "hover:bg-transparent", children: [columns.map((column) => (_jsx(TableHead, { title: column.label, className: cn("truncate", columnAlignClass(column.type)), children: column.label }, column.field))), showControls && (_jsx(TableHead, { className: cn("sticky right-0 z-10 whitespace-nowrap bg-muted px-1 text-right", isHorizontallyScrollable &&
280
280
  "shadow-[inset_1px_0_0_var(--color-border)]"), "aria-label": "Actions" }))] }) }), _jsx(TableBody, { children: rows.map((row, rowIndex) => {
281
281
  const isLastRow = rowIndex === rows.length - 1;
282
282
  const rowIssuesForRow = rowIssues?.[rowIndex];