@cosmicdrift/kumiko-renderer 0.402.0 → 0.404.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.
@@ -23,6 +23,8 @@ import { FacetCountBridges } from "./facet-count-bridge.js";
23
23
  import { useListBulk } from "./list-bulk-actions.js";
24
24
  import { isDefaultFilterActive, isListNarrowed, useListEmptyContent, } from "./list-empty-content.js";
25
25
  import { buildEntityFilterFacets, buildFacetCountRequests, buildFilterPayload, mergeReferenceFacetOptions, normalizeDefaultFilters, } from "./list-facets.js";
26
+ import { buildNamedFilterFacet, routeFacetChange, withNamedFilterValues, } from "./named-filter-facet.js";
27
+ import { NamedFilterRemove } from "./named-filter-remove.js";
26
28
  import { useNav } from "./nav.js";
27
29
  import { QueryErrorBanner, QueryLoadingBanner } from "./query-state-banners.js";
28
30
  import { ReferenceFacetBridges, withReferenceFacetState, } from "./reference-facet-bridge.js";
@@ -64,6 +66,8 @@ function EntityListBody({ schema, screen, entity, translate, onRowClick, }) {
64
66
  // author default vs user choice.
65
67
  const defaultFilters = useMemo(() => normalizeDefaultFilters(screen.defaultFilters), [screen.defaultFilters]);
66
68
  const urlState = useListUrlState(screen.id, defaultFilters);
69
+ // Gated on the screen offering named filters, so a stale URL cannot send a param the list rejects.
70
+ const activeNamedFilter = screen.namedFilters === undefined ? undefined : urlState.namedFilter;
67
71
  const effectiveSort = urlState.sort ?? screen.defaultSort ?? null;
68
72
  const limit = urlState.pageSize ?? screen.pageSize ?? 50;
69
73
  const paginationMode = screen.pagination ?? "pages";
@@ -111,8 +115,11 @@ function EntityListBody({ schema, screen, entity, translate, onRowClick, }) {
111
115
  const filters = [...filterPayload, ...range.rangeFilters];
112
116
  if (filters.length > 0)
113
117
  payload["filters"] = filters;
118
+ if (activeNamedFilter !== undefined)
119
+ payload["namedFilter"] = activeNamedFilter;
114
120
  return payload;
115
121
  }, [
122
+ activeNamedFilter,
116
123
  limit,
117
124
  urlState.q,
118
125
  effectiveSort,
@@ -157,7 +164,22 @@ function EntityListBody({ schema, screen, entity, translate, onRowClick, }) {
157
164
  const handleEntityFacetOptions = useCallback((field, state) => setEntityReferenceFacetOptions((prev) => withReferenceFacetState(prev, field, state)), []);
158
165
  const [facetCounts, setFacetCounts] = useState({});
159
166
  const handleFacetCount = useCallback((key, count) => setFacetCounts((prev) => (prev[key] === count ? prev : { ...prev, [key]: count })), []);
160
- const filterFacets = useMemo(() => buildEntityFilterFacets(mergeReferenceFacetOptions(entityFacetSpecs, entityReferenceFacetOptions), screen.facets, effectiveTranslate, facetCounts), [entityFacetSpecs, entityReferenceFacetOptions, screen.facets, effectiveTranslate, facetCounts]);
167
+ const filterFacets = useMemo(() => {
168
+ const namedFacet = buildNamedFilterFacet(screen.namedFilters, effectiveTranslate);
169
+ return [
170
+ ...(namedFacet !== undefined ? [namedFacet] : []),
171
+ ...buildEntityFilterFacets(mergeReferenceFacetOptions(entityFacetSpecs, entityReferenceFacetOptions), screen.facets, effectiveTranslate, facetCounts),
172
+ ];
173
+ }, [
174
+ entityFacetSpecs,
175
+ entityReferenceFacetOptions,
176
+ screen.facets,
177
+ screen.namedFilters,
178
+ effectiveTranslate,
179
+ facetCounts,
180
+ ]);
181
+ const filterValues = useMemo(() => withNamedFilterValues(urlState.filters, activeNamedFilter), [urlState.filters, activeNamedFilter]);
182
+ const onFilterChange = useMemo(() => routeFacetChange(urlState), [urlState]);
161
183
  const facetCountQueries = useMemo(() => buildFacetCountRequests(entityFacetSpecs, screen.facets, effectiveTranslate).map((request) => {
162
184
  const filters = [
163
185
  ...buildFilterPayload({ ...urlState.filters, [request.field]: request.values }, (field) => entity.fields[field]?.type),
@@ -171,9 +193,11 @@ function EntityListBody({ schema, screen, entity, translate, onRowClick, }) {
171
193
  ...(urlState.q !== "" && { search: urlState.q }),
172
194
  ...(screen.filter !== undefined && { filter: screen.filter }),
173
195
  ...(filters.length > 0 && { filters }),
196
+ ...(activeNamedFilter !== undefined && { namedFilter: activeNamedFilter }),
174
197
  },
175
198
  };
176
199
  }), [
200
+ activeNamedFilter,
177
201
  entityFacetSpecs,
178
202
  screen.facets,
179
203
  screen.filter,
@@ -362,7 +386,7 @@ function EntityListBody({ schema, screen, entity, translate, onRowClick, }) {
362
386
  return _jsx(QueryLoadingBanner, {});
363
387
  }
364
388
  if (listRows.error && !listRows.hasData) {
365
- return (_jsx(QueryErrorBanner, { error: listRows.error, translate: effectiveTranslate, onRetry: listRows.retry }));
389
+ return (_jsxs(_Fragment, { children: [_jsx(QueryErrorBanner, { error: listRows.error, translate: effectiveTranslate, onRetry: listRows.retry }), _jsx(NamedFilterRemove, { active: activeNamedFilter !== undefined, translate: effectiveTranslate, onRemove: () => urlState.setNamedFilter(undefined) })] }));
366
390
  }
367
391
  // A failed refetch keeps the last good data on screen.
368
392
  const refetchErrorBanner = listRows.error ? (_jsx(QueryErrorBanner, { error: listRows.error, translate: effectiveTranslate, onRetry: listRows.retry })) : null;
@@ -395,15 +419,16 @@ function EntityListBody({ schema, screen, entity, translate, onRowClick, }) {
395
419
  // arrives async; until it is there RenderList renders the table without a
396
420
  // pager (the pager has its own total>0 guard).
397
421
  const pager = listPager({ usePager, page: listRows.page, limit, urlState });
398
- return (_jsxs(_Fragment, { children: [refetchErrorBanner, _jsx(ListGroupPageBridges, { ...listTable.aggregates.groupPages }), _jsx(ReferenceFacetBridges, { specs: entityFacetSpecs, selected: urlState.filters, onState: handleEntityFacetOptions }), _jsx(FacetCountBridges, { queryType: queryType, queries: facetCountQueries, onCount: handleFacetCount, refreshNonce: facetCountRefreshNonce }), _jsx(RenderList, { screen: screen, entity: entity, rows: listRows.rows, featureName: featureName, searchable: searchable, narrowed: isListNarrowed(urlState.q, urlState.filters, defaultFilters), defaultFilterActive: isDefaultFilterActive(urlState.q, urlState.filters, defaultFilters), ...(screen.description !== undefined && {
422
+ return (_jsxs(_Fragment, { children: [refetchErrorBanner, _jsx(ListGroupPageBridges, { ...listTable.aggregates.groupPages }), _jsx(ReferenceFacetBridges, { specs: entityFacetSpecs, selected: urlState.filters, onState: handleEntityFacetOptions }), _jsx(FacetCountBridges, { queryType: queryType, queries: facetCountQueries, onCount: handleFacetCount, refreshNonce: facetCountRefreshNonce }), _jsx(RenderList, { screen: screen, entity: entity, rows: listRows.rows, featureName: featureName, searchable: searchable, narrowed: activeNamedFilter !== undefined ||
423
+ isListNarrowed(urlState.q, urlState.filters, defaultFilters), defaultFilterActive: isDefaultFilterActive(urlState.q, urlState.filters, defaultFilters), ...(screen.description !== undefined && {
399
424
  description: effectiveTranslate(screen.description),
400
425
  }), ...(emptyContent !== undefined && { emptyContent }), searchValue: urlState.q, onSearchChange: urlState.setQ, sort: effectiveSort, onSortChange: urlState.setSort, ...(isEmbeddedScreen
401
426
  ? { screenPadding: false, chromeless: true }
402
- : { screenPadding: true }), ...(screenFillsHeight(screen) && { scrollBody: true }), ...(pager !== undefined && { pager }), ...(rowActions !== undefined && { rowActions }), ...(screen.rowActionMode !== undefined && { rowActionMode: screen.rowActionMode }), ...(bulk !== undefined && { bulk }), ...(toolbarActions !== undefined && toolbarActions.length > 0 && { toolbarActions }), ...listRows.infinite, ...(onCreate !== undefined && { onCreate }), ...(translate !== undefined && { translate }), ...(wrappedOnRowClick !== undefined && { onRowClick: wrappedOnRowClick }), ...(filterFacets.length > 0 && { filterFacets, onFilterChange: urlState.setFilter }), ...(range.rangeFacets.length > 0 && {
427
+ : { screenPadding: true }), ...(screenFillsHeight(screen) && { scrollBody: true }), ...(pager !== undefined && { pager }), ...(rowActions !== undefined && { rowActions }), ...(screen.rowActionMode !== undefined && { rowActionMode: screen.rowActionMode }), ...(bulk !== undefined && { bulk }), ...(toolbarActions !== undefined && toolbarActions.length > 0 && { toolbarActions }), ...listRows.infinite, ...(onCreate !== undefined && { onCreate }), ...(translate !== undefined && { translate }), ...(wrappedOnRowClick !== undefined && { onRowClick: wrappedOnRowClick }), ...(filterFacets.length > 0 && { filterFacets, onFilterChange }), ...(range.rangeFacets.length > 0 && {
403
428
  rangeFacets: range.rangeFacets,
404
429
  onRangeChange: range.onRangeChange,
405
430
  }), ...((filterFacets.length > 0 || range.rangeFacets.length > 0) && {
406
- filterValues: urlState.filters,
431
+ filterValues,
407
432
  onFilterReset: urlState.clearFilters,
408
433
  }), ...listTable.props, ...(expandableSection !== undefined && {
409
434
  expandedRowIds,
@@ -0,0 +1,12 @@
1
+ import type { NamedListFilterRef } from "@cosmicdrift/kumiko-types/named-list-filters";
2
+ import type { Translate } from "../headless/index.ts";
3
+ import type { ListUrlStateApi } from "../hooks/use-list-url-state.ts";
4
+ import type { DataTableFacet } from "../primitives.tsx";
5
+ export declare const NAMED_FILTER_FACET_FIELD = "__namedFilter";
6
+ type NamedFilterUrlState = Pick<ListUrlStateApi, "namedFilter" | "setFilter" | "setNamedFilter">;
7
+ /** One toggle chip per named filter, rendered by the existing facet chip row. */
8
+ export declare function buildNamedFilterFacet(refs: readonly NamedListFilterRef[] | undefined, translate: Translate): DataTableFacet | undefined;
9
+ export declare function withNamedFilterValues(filters: Readonly<Record<string, readonly string[]>>, namedFilter: string | undefined): Readonly<Record<string, readonly string[]>>;
10
+ /** Pressing the active chip again removes it. */
11
+ export declare function routeFacetChange(urlState: NamedFilterUrlState): (field: string, values: readonly string[]) => void;
12
+ export {};
@@ -0,0 +1,30 @@
1
+ // Not a real field: keeps the named filter out of the `.f.<field>` facet namespace.
2
+ export const NAMED_FILTER_FACET_FIELD = "__namedFilter";
3
+ /** One toggle chip per named filter, rendered by the existing facet chip row. */
4
+ export function buildNamedFilterFacet(refs, translate) {
5
+ if (refs === undefined || refs.length === 0)
6
+ return undefined;
7
+ return {
8
+ field: NAMED_FILTER_FACET_FIELD,
9
+ label: refs.map((ref) => translate(ref.label)).join(" / "),
10
+ options: refs.map((ref) => ({ value: ref.id, label: translate(ref.label) })),
11
+ chips: refs.map((ref) => ({ id: ref.id, label: translate(ref.label), values: [ref.id] })),
12
+ };
13
+ }
14
+ export function withNamedFilterValues(filters, namedFilter) {
15
+ return namedFilter === undefined
16
+ ? filters
17
+ : { ...filters, [NAMED_FILTER_FACET_FIELD]: [namedFilter] };
18
+ }
19
+ /** Pressing the active chip again removes it. */
20
+ export function routeFacetChange(urlState) {
21
+ return (field, values) => {
22
+ if (field !== NAMED_FILTER_FACET_FIELD) {
23
+ urlState.setFilter(field, values);
24
+ // skip: ordinary facet handled above
25
+ return;
26
+ }
27
+ const picked = values[0];
28
+ urlState.setNamedFilter(picked === urlState.namedFilter ? undefined : picked);
29
+ };
30
+ }
@@ -0,0 +1,8 @@
1
+ import type { ReactNode } from "react";
2
+ import type { Translate } from "../headless/index.ts";
3
+ /** Keeps a stale or forbidden named filter removable while the list shows its error state. */
4
+ export declare function NamedFilterRemove({ active, translate, onRemove, }: {
5
+ readonly active: boolean;
6
+ readonly translate: Translate;
7
+ readonly onRemove: () => void;
8
+ }): ReactNode;
@@ -0,0 +1,9 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { usePrimitives } from "../primitives.js";
3
+ /** Keeps a stale or forbidden named filter removable while the list shows its error state. */
4
+ export function NamedFilterRemove({ active, translate, onRemove, }) {
5
+ const { Button } = usePrimitives();
6
+ if (!active)
7
+ return null;
8
+ return (_jsx(Button, { variant: "secondary", onClick: onRemove, testId: "named-filter-remove", children: translate("kumiko.list.filter.reset") }));
9
+ }
@@ -8,6 +8,7 @@ import { usePrimitives } from "../primitives.js";
8
8
  import { useDrawerAction } from "./action-form-body.js";
9
9
  import { useAppFeatures } from "./app-features-context.js";
10
10
  import { useClientActionRuntime } from "./client-actions.js";
11
+ import { NamedFilterRemove } from "./named-filter-remove.js";
11
12
  import { useNav } from "./nav.js";
12
13
  import { renderProjectionDrawerHost } from "./projection-drawer-host.js";
13
14
  import { renderBadShapeBanner, renderProjectionList, renderQueryRetryBanner, } from "./projection-list-render.js";
@@ -80,7 +81,7 @@ export function ProjectionListBody({ schema, screen, translate, onRowClick, }) {
80
81
  return _jsx(QueryLoadingBanner, {});
81
82
  }
82
83
  if (listRows.error && !listRows.hasData) {
83
- return renderQueryRetryBanner(listRows, listRows.error, effectiveTranslate);
84
+ return (_jsxs(_Fragment, { children: [renderQueryRetryBanner(listRows, listRows.error, effectiveTranslate), _jsx(NamedFilterRemove, { active: query.activeNamedFilter !== undefined, translate: effectiveTranslate, onRemove: () => query.urlState.setNamedFilter(undefined) })] }));
84
85
  }
85
86
  // A failed refetch keeps the last good data on screen.
86
87
  const refetchErrorBanner = listRows.error
@@ -50,7 +50,7 @@ export declare function projectionFacetProps(filterFacets: ReturnType<typeof use
50
50
  dateRangeFacets?: import("../data-table-facet-types.ts").DataTableDateRangeFacet[] | undefined;
51
51
  onDateRangeChange?: ((field: string, bound: "from" | "to", value: string) => void) | undefined;
52
52
  };
53
- export declare function projectionFilterStateProps(filterFacets: ReturnType<typeof useProjectionListData>["filterFacets"], dateRangeFacets: ReturnType<typeof useProjectionListQuery>["dateRangeFacets"], urlState: ListUrlStateApi): {
53
+ export declare function projectionFilterStateProps(filterFacets: ReturnType<typeof useProjectionListData>["filterFacets"], dateRangeFacets: ReturnType<typeof useProjectionListQuery>["dateRangeFacets"], urlState: ListUrlStateApi, activeNamedFilter: string | undefined): {
54
54
  filterValues?: Readonly<Record<string, readonly string[]>> | undefined;
55
55
  onFilterReset?: (() => void) | undefined;
56
56
  };
@@ -1,4 +1,5 @@
1
1
  import { screenFillsHeight } from "../screen-fills-height.js";
2
+ import { routeFacetChange, withNamedFilterValues } from "./named-filter-facet.js";
2
3
  export function projectionLeadProps(screen, effectiveTranslate, emptyContent) {
3
4
  return {
4
5
  ...(screen.description !== undefined && {
@@ -29,7 +30,7 @@ export function projectionFacetProps(filterFacets, dateRangeFacets, onDateRangeC
29
30
  return {
30
31
  ...(filterFacets.length > 0 && {
31
32
  filterFacets,
32
- onFilterChange: urlState.setFilter,
33
+ onFilterChange: routeFacetChange(urlState),
33
34
  }),
34
35
  ...(dateRangeFacets.length > 0 && {
35
36
  dateRangeFacets,
@@ -37,10 +38,10 @@ export function projectionFacetProps(filterFacets, dateRangeFacets, onDateRangeC
37
38
  }),
38
39
  };
39
40
  }
40
- export function projectionFilterStateProps(filterFacets, dateRangeFacets, urlState) {
41
+ export function projectionFilterStateProps(filterFacets, dateRangeFacets, urlState, activeNamedFilter) {
41
42
  return {
42
43
  ...((filterFacets.length > 0 || dateRangeFacets.length > 0) && {
43
- filterValues: urlState.filters,
44
+ filterValues: withNamedFilterValues(urlState.filters, activeNamedFilter),
44
45
  onFilterReset: urlState.clearFilters,
45
46
  }),
46
47
  };
@@ -16,5 +16,6 @@ export function renderProjectionList(model) {
16
16
  const { urlState } = query;
17
17
  const { filterFacets, listRows, listTable } = data;
18
18
  const { dateRangeFacets } = query;
19
- return (_jsx(RenderList, { screen: definitions.listScreen, entity: definitions.entity, rows: listRows.rows, featureName: schema.featureName, searchable: definitions.searchable, narrowed: isListNarrowed(query.activeSearch, urlState.filters), ...projectionLeadProps(screen, effectiveTranslate, actions.emptyContent), searchValue: urlState.q, onSearchChange: urlState.setQ, sort: query.activeSort, onSortChange: urlState.setSort, ...projectionLayoutProps(isEmbeddedScreen, screen, definitions.listScreen, pager, actions.rowActions), ...projectionActionProps(actions, translate, wrappedOnRowClick), ...projectionFacetProps(filterFacets, dateRangeFacets, query.handleDateRangeChange, urlState), ...listTable.props, ...projectionFilterStateProps(filterFacets, dateRangeFacets, urlState) }));
19
+ return (_jsx(RenderList, { screen: definitions.listScreen, entity: definitions.entity, rows: listRows.rows, featureName: schema.featureName, searchable: definitions.searchable, narrowed: query.activeNamedFilter !== undefined ||
20
+ isListNarrowed(query.activeSearch, urlState.filters), ...projectionLeadProps(screen, effectiveTranslate, actions.emptyContent), searchValue: urlState.q, onSearchChange: urlState.setQ, sort: query.activeSort, onSortChange: urlState.setSort, ...projectionLayoutProps(isEmbeddedScreen, screen, definitions.listScreen, pager, actions.rowActions), ...projectionActionProps(actions, translate, wrappedOnRowClick), ...projectionFacetProps(filterFacets, dateRangeFacets, query.handleDateRangeChange, urlState), ...listTable.props, ...projectionFilterStateProps(filterFacets, dateRangeFacets, urlState, query.activeNamedFilter) }));
20
21
  }
@@ -1,5 +1,6 @@
1
1
  import { useMemo } from "react";
2
2
  import { buildFilterFacets } from "./list-facets.js";
3
+ import { buildNamedFilterFacet } from "./named-filter-facet.js";
3
4
  import { useScreenListRows } from "./use-screen-list-rows.js";
4
5
  import { useScreenListTable } from "./use-screen-list-table.js";
5
6
  export function useProjectionListData(input) {
@@ -21,6 +22,12 @@ export function useProjectionListData(input) {
21
22
  infinite: false,
22
23
  aggregates: listTable.aggregates,
23
24
  });
24
- const filterFacets = useMemo(() => buildFilterFacets(resolvedFacetSpecs), [resolvedFacetSpecs]);
25
+ const filterFacets = useMemo(() => {
26
+ const namedFacet = buildNamedFilterFacet(screen.namedFilters, effectiveTranslate);
27
+ return [
28
+ ...(namedFacet !== undefined ? [namedFacet] : []),
29
+ ...buildFilterFacets(resolvedFacetSpecs),
30
+ ];
31
+ }, [resolvedFacetSpecs, screen.namedFilters, effectiveTranslate]);
25
32
  return { listTable, listRows, filterFacets };
26
33
  }
@@ -24,5 +24,6 @@ export declare function useProjectionListQuery(input: ProjectionListQueryInput):
24
24
  dateRangeFacets: import("../data-table-facet-types.ts").DataTableDateRangeFacet[];
25
25
  handleDateRangeChange: (field: string, bound: "from" | "to", value: string) => void;
26
26
  queryPayload: Record<string, unknown>;
27
+ activeNamedFilter: string | undefined;
27
28
  };
28
29
  export {};
@@ -15,6 +15,7 @@ function resolveActiveSort(urlSort, input) {
15
15
  export function useProjectionListQuery(input) {
16
16
  const { screen, featureName, effectiveTranslate, searchable, paginated } = input;
17
17
  const urlState = useListUrlState(screen.id);
18
+ const activeNamedFilter = screen.namedFilters === undefined ? undefined : urlState.namedFilter;
18
19
  // Gated on the derived capability, not just on whether URL-state happens to
19
20
  // carry a value — a stale/hand-crafted URL (?…q=x on a non-searchable
20
21
  // screen) must not smuggle a param the query's Zod schema doesn't accept.
@@ -55,9 +56,12 @@ export function useProjectionListQuery(input) {
55
56
  if (screen.facets !== undefined && filterPayload.length > 0) {
56
57
  payload["filters"] = filterPayload;
57
58
  }
59
+ if (activeNamedFilter !== undefined)
60
+ payload["namedFilter"] = activeNamedFilter;
58
61
  Object.assign(payload, dateRangePayload);
59
62
  return payload;
60
63
  }, [
64
+ activeNamedFilter,
61
65
  limit,
62
66
  activeSearch,
63
67
  activeSort,
@@ -80,5 +84,6 @@ export function useProjectionListQuery(input) {
80
84
  dateRangeFacets,
81
85
  handleDateRangeChange,
82
86
  queryPayload,
87
+ activeNamedFilter,
83
88
  };
84
89
  }
@@ -15,6 +15,8 @@ export type ListUrlState = {
15
15
  /** Active faceted filters: field → selected values (strings; callers coerce
16
16
  * them according to the field type). Empty when no filter is set. */
17
17
  readonly filters: Readonly<Record<string, readonly string[]>>;
18
+ /** Id of the active named list filter; undefined when none is set. */
19
+ readonly namedFilter: string | undefined;
18
20
  };
19
21
  export type ListUrlStateApi = ListUrlState & {
20
22
  /** Atomically sets sort + dir. null means unsorted and clears both keys. */
@@ -29,7 +31,9 @@ export type ListUrlStateApi = ListUrlState & {
29
31
  /** Sets the selected values for a facet field. An empty array clears the key
30
32
  * and resets the page, like sort/search changes. */
31
33
  readonly setFilter: (field: string, values: readonly string[]) => void;
32
- /** Clears all active faceted filters (for a reset button). */
34
+ /** Sets or (undefined) clears the named list filter and resets the page. */
35
+ readonly setNamedFilter: (id: string | undefined) => void;
36
+ /** Clears all active faceted filters and the named filter (for a reset button). */
33
37
  readonly clearFilters: () => void;
34
38
  /** Sets both bounds of a dateRange facet in ONE params update — two
35
39
  * setFilter calls would overwrite each other, since the second still sees
@@ -43,4 +47,6 @@ export type ListUrlStateApi = ListUrlState & {
43
47
  export declare const PAGE_SIZE_OPTIONS: readonly number[];
44
48
  /** URL key of a list facet filter. Its value is the comma-joined selected values. */
45
49
  export declare function listFilterUrlKey(screenId: string, field: string): string;
50
+ /** URL key of the named list filter id. Distinct from the `f.` facet prefix. */
51
+ export declare function listNamedFilterUrlKey(screenId: string): string;
46
52
  export declare function useListUrlState(screenId: string, defaultFilters?: Readonly<Record<string, readonly string[]>>): ListUrlStateApi;
@@ -11,6 +11,8 @@
11
11
  // <screenId>.f.<field> — faceted filter: comma-joined selected values;
12
12
  // NO_FILTER_URL_VALUE = "no filter", chosen deliberately
13
13
  // for a field that has a declared default
14
+ // <screenId>.filter — id of a named list filter (EntityDefinition.namedFilters);
15
+ // while set, the screen's defaultFilters do not apply
14
16
  //
15
17
  // Uses setSearchParams (replaceState, not push) so sort/filter toggles do not
16
18
  // flood the browser history.
@@ -38,6 +40,10 @@ function key(screenId, suffix) {
38
40
  export function listFilterUrlKey(screenId, field) {
39
41
  return key(screenId, `f.${field}`);
40
42
  }
43
+ /** URL key of the named list filter id. Distinct from the `f.` facet prefix. */
44
+ export function listNamedFilterUrlKey(screenId) {
45
+ return key(screenId, "filter");
46
+ }
41
47
  function parseDir(value) {
42
48
  return value === "asc" || value === "desc" ? value : undefined;
43
49
  }
@@ -60,6 +66,10 @@ export function useListUrlState(screenId, defaultFilters) {
60
66
  const q = params[key(screenId, "q")] ?? "";
61
67
  const page = parsePage(params[key(screenId, "page")]);
62
68
  const pageSize = parsePageSize(params[key(screenId, "size")]);
69
+ const namedFilterParam = params[listNamedFilterUrlKey(screenId)];
70
+ const namedFilter = namedFilterParam === "" ? undefined : namedFilterParam;
71
+ // The named filter defines the view, so author defaults must not narrow it further.
72
+ const effectiveDefaultFilters = namedFilter === undefined ? defaultFilters : undefined;
63
73
  const filterPrefix = listFilterUrlKey(screenId, "");
64
74
  const filters = useMemo(() => {
65
75
  const out = {};
@@ -73,15 +83,15 @@ export function useListUrlState(screenId, defaultFilters) {
73
83
  // commas; use repeated keys if comma-containing values are ever needed.
74
84
  out[field] = v === NO_FILTER_URL_VALUE ? [] : v.split(",").filter((s) => s !== "");
75
85
  }
76
- for (const [field, values] of Object.entries(defaultFilters ?? {})) {
86
+ for (const [field, values] of Object.entries(effectiveDefaultFilters ?? {})) {
77
87
  if (!Object.hasOwn(out, field))
78
88
  out[field] = values;
79
89
  }
80
90
  return out;
81
- }, [params, filterPrefix, defaultFilters]);
82
- const clearedFilterValue = useCallback((field) => defaultFilters !== undefined && Object.hasOwn(defaultFilters, field)
91
+ }, [params, filterPrefix, effectiveDefaultFilters]);
92
+ const clearedFilterValue = useCallback((field) => effectiveDefaultFilters !== undefined && Object.hasOwn(effectiveDefaultFilters, field)
83
93
  ? NO_FILTER_URL_VALUE
84
- : null, [defaultFilters]);
94
+ : null, [effectiveDefaultFilters]);
85
95
  const setSort = useCallback((next) => {
86
96
  // Atomically reset the page on every sort change; otherwise the user could
87
97
  // land on page 5 of the old ordering, which is visually confusing. The
@@ -124,8 +134,17 @@ export function useListUrlState(screenId, defaultFilters) {
124
134
  [key(screenId, "page")]: null,
125
135
  });
126
136
  }, [nav, screenId]);
137
+ const setNamedFilter = useCallback((id) => {
138
+ nav.setSearchParams({
139
+ [listNamedFilterUrlKey(screenId)]: id ?? null,
140
+ [key(screenId, "page")]: null,
141
+ });
142
+ }, [nav, screenId]);
127
143
  const clearFilters = useCallback(() => {
128
- const updates = { [key(screenId, "page")]: null };
144
+ const updates = {
145
+ [key(screenId, "page")]: null,
146
+ [listNamedFilterUrlKey(screenId)]: null,
147
+ };
129
148
  for (const field of Object.keys(filters)) {
130
149
  updates[key(screenId, `f.${field}`)] = clearedFilterValue(field);
131
150
  }
@@ -137,11 +156,13 @@ export function useListUrlState(screenId, defaultFilters) {
137
156
  page,
138
157
  pageSize,
139
158
  filters,
159
+ namedFilter,
140
160
  setSort,
141
161
  setQ,
142
162
  setPage,
143
163
  setPageSize,
144
164
  setFilter,
165
+ setNamedFilter,
145
166
  clearFilters,
146
167
  setDateRange,
147
168
  };
@@ -5,6 +5,7 @@ import { isOneOf } from "@cosmicdrift/kumiko-types/type-guards";
5
5
  import { useEffect, useState } from "react";
6
6
  import { rendererWarn } from "../../dev-console.js";
7
7
  import { EMPTY_VALUE_PLACEHOLDER } from "../../headless/index.js";
8
+ import { listNamedFilterUrlKey } from "../../hooks/use-list-url-state.js";
8
9
  import { extensionSectionName, lastSegment, useExtensionSectionComponent, useNav, usePrimitives, useTranslation, useUserRoles, } from "../../index.js";
9
10
  import { initialWindowSelection, } from "../widgets/chart-window.js";
10
11
  import { chartToneColor, SegmentBarChart, StackedAreaChart, StackedBarChart, TimeseriesChart, } from "../widgets/charts.js";
@@ -41,6 +42,8 @@ function fallbackText(raw, placeholder) {
41
42
  }
42
43
  export function StatPanelBody({ panel, label, screenId, screenParams, translate, variant, }) {
43
44
  const formats = useDashboardFormats();
45
+ const nav = useNav();
46
+ const link = statNavigationLink(nav, panel.navigate);
44
47
  // Resolved HERE (not in a separate always-rendered child) so `icon` on
45
48
  // <StatCard> is `undefined` — not a React element that renders empty —
46
49
  // when the icon name isn't registered. StatCard gates its accent chip on
@@ -54,7 +57,9 @@ export function StatPanelBody({ panel, label, screenId, screenParams, translate,
54
57
  `"${iconName}", which is not registered in clientFeatures.extensionSectionComponents.`);
55
58
  }
56
59
  }, [panel.icon, panel.id, iconName, Icon, screenId]);
57
- const { data, error, loading, refetch } = useDashboardQuery(panel.query, panelPayload(panel, screenParams));
60
+ const { data, error, loading, refetch } = useDashboardQuery(panel.query,
61
+ // A navigate tile counts exactly what its target list can reproduce from the URL.
62
+ panel.navigate === undefined ? panelPayload(panel, screenParams) : (panel.params ?? {}));
58
63
  useReportPanelLoaded(data);
59
64
  if (loading && data === null)
60
65
  return _jsx(LoadingState, { rows: 2 });
@@ -86,9 +91,21 @@ export function StatPanelBody({ panel, label, screenId, screenParams, translate,
86
91
  const subText = resolveDashboardText(sub, translate, formats) ?? fallbackText(sub, undefined);
87
92
  const icon = Icon !== undefined ? (_jsx(Icon, { entityName: screenId, entityId: null, screenId: screenId, filterParams: screenParams.filterParams })) : undefined;
88
93
  if (variant === "strip") {
89
- return (_jsx(StatStripCell, { icon: icon, label: label, value: value, tone: tone ?? "default", ...(Icon !== undefined && { accentColor: panel.accentColor }), ...(subText !== undefined && { sub: subText }), ...(delta !== undefined && { delta }), ...(spark !== undefined && { spark }), testId: testId }));
94
+ return (_jsx(StatStripCell, { icon: icon, label: label, value: value, tone: tone ?? "default", ...(Icon !== undefined && { accentColor: panel.accentColor }), ...(subText !== undefined && { sub: subText }), ...(delta !== undefined && { delta }), ...(spark !== undefined && { spark }), testId: testId, ...(link !== undefined && { link }) }));
90
95
  }
91
- return (_jsx(StatCard, { icon: icon, label: label, value: value, tone: tone ?? "default", ...(Icon !== undefined && { accentColor: panel.accentColor }), ...(subText !== undefined && { sub: subText }), ...(delta !== undefined && { delta }), ...(spark !== undefined && { spark }), testId: testId }));
96
+ return (_jsx(StatCard, { icon: icon, label: label, value: value, tone: tone ?? "default", ...(Icon !== undefined && { accentColor: panel.accentColor }), ...(subText !== undefined && { sub: subText }), ...(delta !== undefined && { delta }), ...(spark !== undefined && { spark }), testId: testId, ...(link !== undefined && { link }) }));
97
+ }
98
+ function statNavigationLink(nav, navigate) {
99
+ if (navigate === undefined)
100
+ return undefined;
101
+ const target = { screenId: lastSegment(navigate.screen) };
102
+ const options = navigate.namedFilter === undefined
103
+ ? undefined
104
+ : { searchParams: { [listNamedFilterUrlKey(target.screenId)]: navigate.namedFilter } };
105
+ return {
106
+ href: nav.hrefFor(target, options),
107
+ onPress: () => nav.navigate(target, options),
108
+ };
92
109
  }
93
110
  function readDelta(panel, record) {
94
111
  if (panel.deltaField === undefined || panel.deltaDirectionField === undefined)
@@ -10,6 +10,8 @@ export type CreateEventSourceLiveEventsOptions = {
10
10
  readonly onSessionEnded?: () => void;
11
11
  /** Reconnect-timer seam; defaults to the global setTimeout/clearTimeout. */
12
12
  readonly timers?: LiveEventTimers;
13
+ /** Stall-watchdog timer seam; defaults to the global setTimeout/clearTimeout. */
14
+ readonly stallTimers?: LiveEventTimers;
13
15
  /** Jitter source in [0, 1); defaults to Math.random. */
14
16
  readonly random?: () => number;
15
17
  /** Clock for synthetic resync events; defaults to Date.now. */
@@ -21,6 +23,7 @@ export type LiveEventTimers = {
21
23
  };
22
24
  export declare const RECONNECT_BASE_DELAY_MS = 1000;
23
25
  export declare const RECONNECT_MAX_DELAY_MS = 30000;
26
+ export declare const SSE_STALL_TIMEOUT_MS = 30000;
24
27
  export declare function reconnectDelayMs(attempt: number, random: number): number;
25
28
  /** Returns an EventSource-backed `LiveEventSubscriber`.
26
29
  * Typically called once during app bootstrap and passed as a value to
@@ -3,6 +3,9 @@ import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
3
3
  import { isSessionEndedError, LIVE_RESYNC_EVENT_TYPE, } from "../../index.js";
4
4
  export const RECONNECT_BASE_DELAY_MS = 1_000;
5
5
  export const RECONNECT_MAX_DELAY_MS = 30_000;
6
+ // Two missed server heartbeats (15 s each, SSE_HEARTBEAT_INTERVAL_MS in framework sse-route.ts).
7
+ // A KEDA interceptor can leave the stream hanging without an `error` event (kedacore/http-add-on#1803).
8
+ export const SSE_STALL_TIMEOUT_MS = 30_000;
6
9
  const defaultTimers = {
7
10
  set: (callback, delayMs) => {
8
11
  const handle = setTimeout(callback, delayMs);
@@ -67,6 +70,7 @@ async function isSessionEndedRejection(url) {
67
70
  export function createEventSourceLiveEvents(options = {}) {
68
71
  const url = options.url ?? "/api/sse";
69
72
  const timers = options.timers ?? defaultTimers;
73
+ const stallTimers = options.stallTimers ?? defaultTimers;
70
74
  const random = options.random ?? Math.random;
71
75
  const now = options.now ?? Date.now;
72
76
  const subscribers = new Set();
@@ -74,6 +78,25 @@ export function createEventSourceLiveEvents(options = {}) {
74
78
  const wiredEntities = new Set();
75
79
  let cancelReconnect;
76
80
  let reconnectAttempt = 0;
81
+ let cancelStallWatchdog;
82
+ const disarmStallWatchdog = () => {
83
+ cancelStallWatchdog?.();
84
+ cancelStallWatchdog = undefined;
85
+ };
86
+ const armStallWatchdog = (connection) => {
87
+ // skip: a frame from a connection that is no longer the current one
88
+ if (source !== connection)
89
+ return;
90
+ disarmStallWatchdog();
91
+ cancelStallWatchdog = stallTimers.set(() => {
92
+ cancelStallWatchdog = undefined;
93
+ // skip: a late timer of a connection that was already replaced
94
+ if (source !== connection)
95
+ return;
96
+ connection.close();
97
+ scheduleReconnect();
98
+ }, SSE_STALL_TIMEOUT_MS);
99
+ };
77
100
  // `subscribers` is a flat Set across all entities — the browser-side
78
101
  // addEventListener(entityName, ...) gate below only filters which frames
79
102
  // arrive at all, not which subscriber a given frame is for. Without this
@@ -119,6 +142,7 @@ export function createEventSourceLiveEvents(options = {}) {
119
142
  return;
120
143
  connection.addEventListener(entityName, (e) => {
121
144
  const data = propertyOf(e, "data");
145
+ armStallWatchdog(connection);
122
146
  if (typeof data === "string")
123
147
  handleEvent(data, entityName);
124
148
  });
@@ -142,14 +166,22 @@ export function createEventSourceLiveEvents(options = {}) {
142
166
  sessionEndedSignalled = true;
143
167
  onSessionEnded?.();
144
168
  };
169
+ armStallWatchdog(connection);
170
+ connection.addEventListener("ping", () => armStallWatchdog(connection));
145
171
  connection.addEventListener("open", () => {
172
+ armStallWatchdog(connection);
146
173
  reconnectAttempt = 0;
147
174
  emitResync();
148
175
  });
149
176
  connection.addEventListener(SESSION_ENDED_EVENT, (e) => {
150
177
  const code = sessionEndedFrameCode(e);
151
- if (code !== undefined && isSessionEndedError({ code, httpStatus: 401 }))
178
+ if (code !== undefined && isSessionEndedError({ code, httpStatus: 401 })) {
179
+ disarmStallWatchdog();
152
180
  signalSessionEnded();
181
+ // skip: a reconnect after a session end would only be refused again
182
+ return;
183
+ }
184
+ armStallWatchdog(connection);
153
185
  });
154
186
  connection.addEventListener("error", () => {
155
187
  // skip: CONNECTING means a dropped stream the browser retries itself;
@@ -161,10 +193,12 @@ export function createEventSourceLiveEvents(options = {}) {
161
193
  if (source !== connection)
162
194
  return;
163
195
  if (sessionEnded) {
196
+ disarmStallWatchdog();
164
197
  signalSessionEnded();
165
198
  // skip: the session ended, reconnecting would only be refused again
166
199
  return;
167
200
  }
201
+ disarmStallWatchdog();
168
202
  scheduleReconnect();
169
203
  });
170
204
  });
@@ -196,6 +230,7 @@ export function createEventSourceLiveEvents(options = {}) {
196
230
  cancelReconnect = undefined;
197
231
  }
198
232
  reconnectAttempt = 0;
233
+ disarmStallWatchdog();
199
234
  // skip: already closed, nothing to close
200
235
  if (source === undefined)
201
236
  return;
@@ -2,6 +2,11 @@ import type { DashboardStatTone } from "@cosmicdrift/kumiko-types/screen";
2
2
  import type { ReactNode } from "react";
3
3
  export type StatTone = DashboardStatTone;
4
4
  export declare const STAT_CARD_GRID_CLASSES = "grid grid-cols-[repeat(auto-fit,minmax(min(12rem,100%),1fr))] gap-3";
5
+ /** Makes a tile a link; `onPress` is the SPA navigation, `href` serves modified clicks and a11y. */
6
+ export type StatLink = {
7
+ readonly href: string;
8
+ readonly onPress: () => void;
9
+ };
5
10
  export type StatDelta = {
6
11
  readonly value: string;
7
12
  readonly direction: "up" | "down";
@@ -13,7 +18,7 @@ export declare function Sparkline({ points, className, }: {
13
18
  }): ReactNode;
14
19
  /** Icon-bearing metric tile with an optional delta chip, trend line,
15
20
  * and sparkline. `icon` is a ready-made node (the app supplies its SVG). */
16
- export declare function StatCard({ icon, label, value, sub, tone, accentColor, delta, trend, spark, testId, children, }: {
21
+ export declare function StatCard({ icon, label, value, sub, tone, accentColor, delta, trend, spark, testId, link, children, }: {
17
22
  readonly icon?: ReactNode;
18
23
  readonly label: string;
19
24
  readonly value: string;
@@ -24,13 +29,14 @@ export declare function StatCard({ icon, label, value, sub, tone, accentColor, d
24
29
  readonly trend?: string;
25
30
  readonly spark?: readonly number[];
26
31
  readonly testId?: string;
32
+ readonly link?: StatLink;
27
33
  /** Extra content below the built-in fields (e.g. a usage meter) — kept
28
34
  * inside the same card chrome instead of stacking a second bordered box. */
29
35
  readonly children?: ReactNode;
30
36
  }): ReactNode;
31
37
  /** Flat KPI cell for a strip (divider instead of card): label, value with
32
38
  * sparkline beside it, sub line. */
33
- export declare function StatStripCell({ icon, label, value, sub, tone, accentColor, delta, spark, testId, }: {
39
+ export declare function StatStripCell({ icon, label, value, sub, tone, accentColor, delta, spark, testId, link, }: {
34
40
  /** Chip before the label, colored like StatCard's (`accentColor` or the tone). */
35
41
  readonly icon?: ReactNode;
36
42
  readonly label: string;
@@ -41,6 +47,7 @@ export declare function StatStripCell({ icon, label, value, sub, tone, accentCol
41
47
  readonly delta?: StatDelta;
42
48
  readonly spark?: readonly number[];
43
49
  readonly testId?: string;
50
+ readonly link?: StatLink;
44
51
  }): ReactNode;
45
52
  /** Compact metric tile (without an icon) — for dense KPI grids.
46
53
  * StatCard is the icon-bearing variant. */
@@ -18,6 +18,12 @@ const TONE_VALUE = {
18
18
  warn: "text-destructive",
19
19
  negative: "text-status-bad",
20
20
  };
21
+ function StatLinkFrame({ link, testId, children, }) {
22
+ const { Link } = usePrimitives();
23
+ if (link === undefined)
24
+ return children;
25
+ return (_jsx(Link, { href: link.href, onPress: link.onPress, className: "block min-w-0 text-inherit hover:no-underline", ...(testId !== undefined && { testId: `${testId}-link` }), children: children }));
26
+ }
21
27
  // Inline SVG sparkline (no chart dependency for a 28px curve): line +
22
28
  // subtle area gradient, colored via currentColor (caller sets text-tone).
23
29
  export function Sparkline({ points, className, }) {
@@ -47,18 +53,19 @@ function StatIconChip({ icon, tone, accentColor, }) {
47
53
  }
48
54
  /** Icon-bearing metric tile with an optional delta chip, trend line,
49
55
  * and sparkline. `icon` is a ready-made node (the app supplies its SVG). */
50
- export function StatCard({ icon, label, value, sub, tone = "default", accentColor, delta, trend, spark, testId, children, }) {
56
+ export function StatCard({ icon, label, value, sub, tone = "default", accentColor, delta, trend, spark, testId, link, children, }) {
51
57
  const { Card } = usePrimitives();
52
58
  // StatCard is a reusable small-tile widget (a metrics dashboard grid, not
53
59
  // a whole-section frame) — an extension section mounting several of
54
60
  // these renders a grid of tiles inside its own host Card, not a
55
61
  // duplicate outer frame around the section's full content.
56
- return (_jsxs(Card, { options: { padded: false }, className: "p-4", testId: testId, children: [_jsxs("div", { className: "flex flex-wrap items-start justify-between gap-x-2 gap-y-1", children: [_jsxs("div", { className: "flex min-w-0 flex-1 basis-[7rem] items-center gap-2 text-muted-foreground", children: [icon !== undefined && _jsx(StatIconChip, { icon: icon, tone: tone, accentColor: accentColor }), _jsx("span", { title: label, className: "min-w-0 line-clamp-2 text-xs font-medium", children: label })] }), delta !== undefined && (_jsxs("span", { className: cn("inline-flex shrink-0 items-center gap-0.5 whitespace-nowrap rounded-full px-1.5 py-0.5 text-xs font-medium tabular-nums", TONE_CHIP[delta.tone ?? tone]), children: [delta.direction === "up" ? "↑" : "↓", delta.value] }))] }), _jsx("div", { className: "mt-2 text-xl font-semibold tabular-nums text-foreground", children: value }), sub !== undefined && _jsx("div", { className: "mt-0.5 text-xs text-muted-foreground", children: sub }), trend !== undefined && (_jsx("div", { className: "mt-0.5 text-xs font-medium text-foreground/80", children: trend })), spark !== undefined && (_jsx(Sparkline, { points: spark, className: cn("mt-2 h-7 w-full", TONE_VALUE[tone]) })), children !== undefined && _jsx("div", { className: "mt-3", children: children })] }));
62
+ const tile = (_jsxs(Card, { options: { padded: false }, className: "p-4", testId: testId, children: [_jsxs("div", { className: "flex flex-wrap items-start justify-between gap-x-2 gap-y-1", children: [_jsxs("div", { className: "flex min-w-0 flex-1 basis-[7rem] items-center gap-2 text-muted-foreground", children: [icon !== undefined && _jsx(StatIconChip, { icon: icon, tone: tone, accentColor: accentColor }), _jsx("span", { title: label, className: "min-w-0 line-clamp-2 text-xs font-medium", children: label })] }), delta !== undefined && (_jsxs("span", { className: cn("inline-flex shrink-0 items-center gap-0.5 whitespace-nowrap rounded-full px-1.5 py-0.5 text-xs font-medium tabular-nums", TONE_CHIP[delta.tone ?? tone]), children: [delta.direction === "up" ? "↑" : "↓", delta.value] }))] }), _jsx("div", { className: "mt-2 text-xl font-semibold tabular-nums text-foreground", children: value }), sub !== undefined && _jsx("div", { className: "mt-0.5 text-xs text-muted-foreground", children: sub }), trend !== undefined && (_jsx("div", { className: "mt-0.5 text-xs font-medium text-foreground/80", children: trend })), spark !== undefined && (_jsx(Sparkline, { points: spark, className: cn("mt-2 h-7 w-full", TONE_VALUE[tone]) })), children !== undefined && _jsx("div", { className: "mt-3", children: children })] }));
63
+ return (_jsx(StatLinkFrame, { link: link, testId: testId, children: tile }));
57
64
  }
58
65
  /** Flat KPI cell for a strip (divider instead of card): label, value with
59
66
  * sparkline beside it, sub line. */
60
- export function StatStripCell({ icon, label, value, sub, tone = "default", accentColor, delta, spark, testId, }) {
61
- return (_jsxs("div", { "data-testid": testId, className: "flex min-w-0 flex-col gap-1 px-4 py-3", children: [_jsxs("div", { className: "flex flex-wrap items-center justify-between gap-x-2 gap-y-1", children: [_jsxs("span", { className: "flex min-w-0 items-center gap-2 text-xs font-medium text-muted-foreground", children: [icon !== undefined && _jsx(StatIconChip, { icon: icon, tone: tone, accentColor: accentColor }), _jsx("span", { children: label })] }), delta !== undefined && (_jsxs("span", { className: cn("inline-flex shrink-0 items-center gap-0.5 whitespace-nowrap rounded-full px-1.5 py-0.5 text-xs font-medium tabular-nums", TONE_CHIP[delta.tone ?? tone]), children: [delta.direction === "up" ? "↑" : "↓", delta.value] }))] }), _jsxs("div", { className: "flex items-center gap-3", children: [_jsx("span", { className: cn("text-xl font-semibold tabular-nums", TONE_VALUE[tone]), children: value }), spark !== undefined && (_jsx(Sparkline, { points: spark, className: cn("h-7 w-20 shrink-0", TONE_VALUE[tone]) }))] }), sub !== undefined && _jsx("div", { className: "text-xs text-muted-foreground", children: sub })] }));
67
+ export function StatStripCell({ icon, label, value, sub, tone = "default", accentColor, delta, spark, testId, link, }) {
68
+ return (_jsx(StatLinkFrame, { link: link, testId: testId, children: _jsxs("div", { "data-testid": testId, className: "flex min-w-0 flex-col gap-1 px-4 py-3", children: [_jsxs("div", { className: "flex flex-wrap items-center justify-between gap-x-2 gap-y-1", children: [_jsxs("span", { className: "flex min-w-0 items-center gap-2 text-xs font-medium text-muted-foreground", children: [icon !== undefined && (_jsx(StatIconChip, { icon: icon, tone: tone, accentColor: accentColor })), _jsx("span", { children: label })] }), delta !== undefined && (_jsxs("span", { className: cn("inline-flex shrink-0 items-center gap-0.5 whitespace-nowrap rounded-full px-1.5 py-0.5 text-xs font-medium tabular-nums", TONE_CHIP[delta.tone ?? tone]), children: [delta.direction === "up" ? "↑" : "↓", delta.value] }))] }), _jsxs("div", { className: "flex items-center gap-3", children: [_jsx("span", { className: cn("text-xl font-semibold tabular-nums", TONE_VALUE[tone]), children: value }), spark !== undefined && (_jsx(Sparkline, { points: spark, className: cn("h-7 w-20 shrink-0", TONE_VALUE[tone]) }))] }), sub !== undefined && _jsx("div", { className: "text-xs text-muted-foreground", children: sub })] }) }));
62
69
  }
63
70
  /** Compact metric tile (without an icon) — for dense KPI grids.
64
71
  * StatCard is the icon-bearing variant. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer",
3
- "version": "0.402.0",
3
+ "version": "0.404.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.402.0",
52
- "@cosmicdrift/kumiko-types": "0.402.0",
51
+ "@cosmicdrift/kumiko-framework": "0.404.0",
52
+ "@cosmicdrift/kumiko-types": "0.404.0",
53
53
  "@types/qrcode": "^1.5.5",
54
54
  "class-variance-authority": "^0.7.1",
55
55
  "clsx": "^2.1.1",
@@ -79,8 +79,8 @@
79
79
  }
80
80
  },
81
81
  "devDependencies": {
82
- "@cosmicdrift/kumiko-locale-de": "0.402.0",
83
- "@cosmicdrift/kumiko-testing": "0.402.0",
82
+ "@cosmicdrift/kumiko-locale-de": "0.404.0",
83
+ "@cosmicdrift/kumiko-testing": "0.404.0",
84
84
  "@tailwindcss/cli": "^4.3.3",
85
85
  "@testing-library/react": "^16.3.3",
86
86
  "@testing-library/user-event": "^14.6.8",
package/src/changes.json CHANGED
@@ -1,4 +1,16 @@
1
1
  [
2
+ {
3
+ "version": "0.404.0",
4
+ "type": "fix",
5
+ "title": "SSE client reconnects after a stalled stream",
6
+ "detail": "createEventSourceLiveEvents arms a 30 s watchdog (two missed server heartbeats) that is reset by open, ping and every entity or session-ended frame. On expiry it closes the connection and reconnects through the existing backoff path, which resyncs all listeners. The timer is cleared on close and error. The seam is the new stallTimers option."
7
+ },
8
+ {
9
+ "version": "0.403.0",
10
+ "type": "improvement",
11
+ "title": "Lists and dashboard stats take named filters",
12
+ "detail": "entityList and projectionList take namedFilters [{ id, label }], shown as a removable chip and kept in the URL as <screenId>.filter; it reaches the list, facet and count queries, and defaultFilters are skipped while it is active. A dashboard stat takes navigate { screen, namedFilter } and becomes a link to the filtered list. The boot validator checks that ids exist, that a projectionList query accepts namedFilter, and that the tile counts with the target list's own query and namedFilter."
13
+ },
2
14
  {
3
15
  "version": "0.402.0",
4
16
  "type": "improvement",