@cosmicdrift/kumiko-renderer-web 0.350.0 → 0.352.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,7 +6,7 @@ import { EmbeddedFormProvider } from "../primitives/index.js";
6
6
  import { PageSection } from "../primitives/layout.js";
7
7
  import { formatMoney } from "../primitives/money-input.js";
8
8
  import { Skeleton } from "../ui/skeleton.js";
9
- import { SegmentBarChart, StackedAreaChart, StackedBarChart, TimeseriesChart, } from "../widgets/charts.js";
9
+ import { chartToneColor, SegmentBarChart, StackedAreaChart, StackedBarChart, TimeseriesChart, } from "../widgets/charts.js";
10
10
  import { DashboardListTable } from "../widgets/dashboard-list.js";
11
11
  import { FeedList } from "../widgets/feed-list.js";
12
12
  import { ModeSwitch } from "../widgets/mode-switch.js";
@@ -116,8 +116,8 @@ function resolveDashboardText(value, translate, formats) {
116
116
  return resolveI18nText(value, translate, formats, 1);
117
117
  return undefined;
118
118
  }
119
- function PanelShell({ testId, title, subtitle, children, }) {
120
- return (_jsxs("section", { "data-testid": testId, className: "flex h-full flex-col rounded-lg border border-border bg-card text-card-foreground", children: [_jsxs("header", { className: "flex h-11 shrink-0 items-center gap-2 border-b border-border-row px-4", children: [_jsx("h2", { className: "truncate text-sm font-semibold", children: title }), subtitle !== undefined && (_jsx("span", { className: "truncate text-xs text-muted-foreground", children: subtitle }))] }), _jsx("div", { className: "grow p-4", children: children })] }));
119
+ function PanelShell({ testId, title, subtitle, action, children, }) {
120
+ return (_jsxs("section", { "data-testid": testId, className: "flex h-full flex-col rounded-lg border border-border bg-card text-card-foreground", children: [_jsxs("header", { className: "flex h-11 shrink-0 items-center gap-2 border-b border-border-row px-4", children: [_jsx("h2", { className: "truncate text-sm font-semibold", children: title }), subtitle !== undefined && (_jsx("span", { className: "truncate text-xs text-muted-foreground", children: subtitle })), action !== undefined && _jsx("div", { className: "ml-auto shrink-0", children: action })] }), _jsx("div", { className: "grow p-4", children: children })] }));
121
121
  }
122
122
  function PanelError({ label, error, onRetry, testId, }) {
123
123
  const { Button } = usePrimitives();
@@ -133,7 +133,7 @@ function PanelSkeleton({ shape }) {
133
133
  }
134
134
  return (_jsx("output", { "aria-label": t("kumiko.widget.loading"), className: "flex h-40 items-end gap-1", children: SKELETON_BAR_HEIGHTS.map((height) => (_jsx(Skeleton, { className: "flex-1", style: { height } }, height))) }));
135
135
  }
136
- function QueryPanel({ panel, label, subtitle, screenParams, translate, skeleton, isEmpty, children, }) {
136
+ function QueryPanel({ panel, label, subtitle, action, screenParams, translate, skeleton, isEmpty, children, }) {
137
137
  const { data, error, loading, refetch } = useQuery(panel.query, panelPayload(panel, screenParams), { live: true });
138
138
  let body;
139
139
  if (loading && data === null) {
@@ -148,7 +148,7 @@ function QueryPanel({ panel, label, subtitle, screenParams, translate, skeleton,
148
148
  else {
149
149
  body = children(data);
150
150
  }
151
- return (_jsx(PanelShell, { testId: `dashboard-panel-${panel.id}`, title: label, subtitle: subtitle, children: body }));
151
+ return (_jsx(PanelShell, { testId: `dashboard-panel-${panel.id}`, title: label, subtitle: subtitle, action: action, children: body }));
152
152
  }
153
153
  function isSparkPoint(value) {
154
154
  return (typeof value === "object" &&
@@ -207,10 +207,11 @@ function StatPanelBody({ panel, label, screenId, screenParams, translate, varian
207
207
  const spark = readSparkline(panel, record);
208
208
  const testId = `dashboard-panel-${panel.id}`;
209
209
  const subText = resolveDashboardText(sub, translate, formats) ?? fallbackText(sub, undefined);
210
+ const icon = Icon !== undefined ? (_jsx(Icon, { entityName: screenId, entityId: null, screenId: screenId, filterParams: screenParams.filterParams })) : undefined;
210
211
  if (variant === "strip") {
211
- return (_jsx(StatStripCell, { label: label, value: value, tone: tone ?? "default", ...(subText !== undefined && { sub: subText }), ...(delta !== undefined && { delta }), ...(spark !== undefined && { spark }), testId: testId }));
212
+ 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 }));
212
213
  }
213
- return (_jsx(StatCard, { icon: Icon !== undefined ? (_jsx(Icon, { entityName: screenId, entityId: null, screenId: screenId, filterParams: screenParams.filterParams })) : undefined, label: label, value: value, tone: tone ?? "default", ...(Icon !== undefined && { accentColor: panel.accentColor }), ...(subText !== undefined && { sub: subText }), ...(delta !== undefined && { delta }), ...(spark !== undefined && { spark }), testId: testId }));
214
+ 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 }));
214
215
  }
215
216
  function readDelta(panel, record) {
216
217
  if (panel.deltaField === undefined || panel.deltaDirectionField === undefined)
@@ -224,18 +225,51 @@ function readDelta(panel, record) {
224
225
  const tone = readTone(panel.deltaToneField !== undefined ? record[panel.deltaToneField] : undefined);
225
226
  return { value: String(value), direction, ...(tone !== undefined && { tone }) };
226
227
  }
228
+ function labeledGroupColumns(valueCount) {
229
+ if (valueCount <= 1)
230
+ return "";
231
+ return valueCount === 2 ? "sm:grid-cols-2" : "sm:grid-cols-3";
232
+ }
227
233
  function StatGroupPanelBody({ panel, label, screenId, screenParams, translate, }) {
228
234
  const testId = `dashboard-panel-${panel.id}`;
229
235
  if (label === undefined) {
230
236
  return (_jsx("div", { "data-testid": testId, className: "grid grid-cols-2 gap-y-2 rounded-lg border border-border bg-card py-1 lg:auto-cols-fr lg:grid-flow-col lg:divide-x lg:divide-border-row", children: panel.stats.map((stat) => (_jsx(StatPanelBody, { panel: stat, label: translate(stat.label), screenId: screenId, screenParams: screenParams, translate: translate, variant: "strip" }, stat.id))) }));
231
237
  }
232
- return (_jsx(SectionCard, { title: label, testId: testId, children: _jsx("section", { className: "grid grid-cols-1 gap-3 sm:grid-cols-3", children: panel.stats.map((stat) => (_jsx(StatPanelBody, { panel: stat, label: translate(stat.label), screenId: screenId, screenParams: screenParams, translate: translate, variant: "card" }, stat.id))) }) }));
238
+ return (_jsx(SectionCard, { title: label, ...(panel.subtitle !== undefined && { subtitle: translate(panel.subtitle) }), testId: testId, children: _jsx("section", { className: `grid grid-cols-1 gap-3 ${labeledGroupColumns(panel.stats.length)}`, children: panel.stats.map((stat) => (_jsx(StatPanelBody, { panel: stat, label: translate(stat.label), screenId: screenId, screenParams: screenParams, translate: translate, variant: "card" }, stat.id))) }) }));
239
+ }
240
+ function markerKindColor(kind) {
241
+ if (kind === undefined)
242
+ return undefined;
243
+ if (kind.color !== undefined)
244
+ return kind.color;
245
+ return kind.tone !== undefined ? chartToneColor(kind.tone) : undefined;
246
+ }
247
+ function resolveMarkers(markers, translate, formats, markerKinds) {
248
+ return markers?.map((marker) => {
249
+ const color = typeof marker.kind === "string" ? markerKindColor(markerKinds?.[marker.kind]) : undefined;
250
+ return {
251
+ atMs: marker.atMs,
252
+ label: resolveDashboardText(marker.label, translate, formats) ?? "",
253
+ ...(color !== undefined && { color }),
254
+ };
255
+ });
233
256
  }
234
- function resolveMarkers(markers, translate, formats) {
235
- return markers?.map((marker) => ({
236
- atMs: marker.atMs,
237
- label: resolveDashboardText(marker.label, translate, formats) ?? "",
238
- }));
257
+ function addUtcMonths(atMs, months) {
258
+ const date = new Date(atMs);
259
+ date.setUTCMonth(date.getUTCMonth() + months);
260
+ return date.getTime();
261
+ }
262
+ // Same window as a "1/3/5 years" switch over a plan: `months` ahead of today,
263
+ // pulled back when the data ends earlier, never before the first bucket.
264
+ function rangeWindow(months, anchorMs, firstMs, lastMs) {
265
+ const endMs = Math.min(lastMs, addUtcMonths(anchorMs, months));
266
+ return { startMs: Math.max(firstMs, addUtcMonths(endMs, -months)), endMs };
267
+ }
268
+ function pointsWithin(points, window) {
269
+ return points.filter((p) => p.atMs >= window.startMs && p.atMs <= window.endMs);
270
+ }
271
+ function selectedRangeMonths(ranges, selected) {
272
+ return ranges?.options.find((option) => option.value === selected)?.months;
239
273
  }
240
274
  // A plain bar chart ("changes per day") ships `points` without `series`.
241
275
  const SINGLE_SERIES_KEY = "value";
@@ -272,18 +306,25 @@ function ChartPanelBody({ panel, label, screenParams, translate, }) {
272
306
  const t = useTranslation();
273
307
  const todayLabel = t("kumiko.dashboard.today");
274
308
  const tones = panel.seriesTones;
309
+ const colors = panel.seriesColors;
275
310
  const testId = `dashboard-chart-${panel.id}`;
276
- return (_jsx(QueryPanel, { panel: panel, label: label, subtitle: panel.subtitle !== undefined ? translate(panel.subtitle) : undefined, screenParams: screenParams, translate: translate, skeleton: panel.chart === "segment-bars" ? "segments" : "bars", isEmpty: (data) => isChartEmpty(panel.chart, data, chartSeries(data, label, translate)), children: (data) => {
311
+ const ranges = panel.chart === "stacked-area" ? panel.ranges : undefined;
312
+ const [selectedRange, setSelectedRange] = useState(ranges?.default);
313
+ const rangeSwitch = ranges !== undefined && selectedRange !== undefined ? (_jsx(ModeSwitch, { value: selectedRange, options: ranges.options.map((option) => ({
314
+ value: option.value,
315
+ label: translate(option.label),
316
+ })), onChange: setSelectedRange, ariaLabel: label, variant: "pill", testId: `dashboard-chart-range-${panel.id}` })) : undefined;
317
+ return (_jsx(QueryPanel, { panel: panel, label: label, subtitle: panel.subtitle !== undefined ? translate(panel.subtitle) : undefined, action: rangeSwitch, screenParams: screenParams, translate: translate, skeleton: panel.chart === "segment-bars" ? "segments" : "bars", isEmpty: (data) => isChartEmpty(panel.chart, data, chartSeries(data, label, translate)), children: (data) => {
277
318
  const series = chartSeries(data, label, translate);
278
319
  const { startMs, endMs } = windowOf(data, series);
279
- const markers = resolveMarkers(data.markers, translate, formats);
320
+ const markers = resolveMarkers(data.markers, translate, formats, panel.markerKinds);
280
321
  const formatValue = formats.formatPanelValue(panel.valueFormat);
281
322
  if (panel.chart === "segment-bars") {
282
323
  return (_jsx(SegmentBarChart, { rows: (data.rows ?? []).map((row) => ({
283
324
  ...row,
284
325
  label: translate(row.label),
285
326
  segments: row.segments.map((seg) => ({ ...seg, label: translate(seg.label) })),
286
- })), tones: tones, ariaLabel: label, formatValue: formatValue, testId: testId }));
327
+ })), tones: tones, colors: colors, ariaLabel: label, formatValue: formatValue, testId: testId }));
287
328
  }
288
329
  if (panel.chart === "stacked-bars") {
289
330
  const times = series.flatMap((s) => s.points.map((p) => p.atMs));
@@ -293,11 +334,25 @@ function ChartPanelBody({ panel, label, screenParams, translate, }) {
293
334
  const prev = all[i - 1];
294
335
  return prev === undefined ? [] : [atMs - prev];
295
336
  }), DAY_MS);
296
- return (_jsx(StackedBarChart, { series: series, windowEndMs: endMs, tones: tones, ariaLabel: label, todayLabel: todayLabel, formatBucketLabel: spacing < DAY_MS - HOUR_MS ? formats.formatHour : formats.formatDay, formatValue: formatValue, testId: testId }));
337
+ return (_jsx(StackedBarChart, { series: series, windowEndMs: endMs, tones: tones, colors: colors, ariaLabel: label, todayLabel: todayLabel, formatBucketLabel: spacing < DAY_MS - HOUR_MS ? formats.formatHour : formats.formatDay, formatValue: formatValue, testId: testId }));
297
338
  }
298
339
  if (panel.chart === "stacked-area") {
299
- return (_jsx(StackedAreaChart, { series: series, windowStartMs: startMs, windowEndMs: endMs, ...(data.todayMs !== undefined &&
300
- data.todayMs !== null && { todayMs: data.todayMs }), tones: tones, markers: markers, ariaLabel: label, todayLabel: todayLabel, formatBucketLabel: formats.formatDay, formatValue: formatValue, formatMarkerTime: formats.formatDay, scrollable: panel.scrollable === true, testId: testId }));
340
+ const lines = (data.lines ?? []).map((line) => ({
341
+ ...line,
342
+ label: translate(line.label),
343
+ }));
344
+ const todayMs = data.todayMs ?? undefined;
345
+ const months = selectedRangeMonths(ranges, selectedRange);
346
+ const bucketTimes = series.flatMap((s) => s.points.map((p) => p.atMs));
347
+ const window = months === undefined || bucketTimes.length === 0
348
+ ? { startMs, endMs }
349
+ : rangeWindow(months, todayMs ?? Math.max(...bucketTimes), Math.min(...bucketTimes), Math.max(...bucketTimes));
350
+ const isWindowed = months !== undefined;
351
+ return (_jsx(StackedAreaChart, { series: isWindowed
352
+ ? series.map((s) => ({ ...s, points: pointsWithin(s.points, window) }))
353
+ : series, lines: isWindowed
354
+ ? lines.map((line) => ({ ...line, points: pointsWithin(line.points, window) }))
355
+ : lines, windowStartMs: window.startMs, windowEndMs: window.endMs, ...(todayMs !== undefined && { todayMs }), tones: tones, colors: colors, markers: isWindowed && markers !== undefined ? pointsWithin(markers, window) : markers, ariaLabel: label, todayLabel: todayLabel, formatBucketLabel: formats.formatDay, formatValue: formatValue, formatMarkerTime: formats.formatDay, scrollable: panel.scrollable === true, showLegendTotals: panel.legendTotals !== false, emptyContent: _jsx(EmptyState, { title: translate(panel.emptyLabel ?? "kumiko.list.no-entries") }), testId: testId }));
301
356
  }
302
357
  return (_jsx(TimeseriesChart, { points: data.points ?? [], windowStartMs: startMs, windowEndMs: endMs, markers: markers, formatMarkerTime: formats.formatDay, ...(panel.valueFormat !== undefined && {
303
358
  yAxis: { ticks: 3, format: formatValue },
@@ -336,12 +391,16 @@ function FeedPanelBody({ panel, label, screenParams, translate, }) {
336
391
  function ProgressListPanelBody({ panel, label, screenParams, translate, }) {
337
392
  const formats = useDashboardFormats();
338
393
  return (_jsx(QueryPanel, { panel: panel, label: label, screenParams: screenParams, translate: translate, skeleton: "rows", isEmpty: (data) => (data.rows ?? []).length === 0, children: (data) => {
339
- const rows = (data.rows ?? []).map((row, i) => ({
340
- id: String(i),
341
- label: resolveDashboardText(row.label, translate, formats) ?? "—",
342
- value: resolveDashboardText(row.value, translate, formats) ?? "—",
343
- fraction: row.fraction,
344
- }));
394
+ const rows = (data.rows ?? []).map((row, i) => {
395
+ const sub = resolveDashboardText(row.sub, translate, formats);
396
+ return {
397
+ id: String(i),
398
+ label: resolveDashboardText(row.label, translate, formats) ?? "—",
399
+ value: resolveDashboardText(row.value, translate, formats) ?? "—",
400
+ fraction: row.fraction,
401
+ ...(sub !== undefined && { sub }),
402
+ };
403
+ });
345
404
  return _jsx(ProgressList, { rows: rows });
346
405
  } }));
347
406
  }
@@ -439,33 +498,67 @@ function PanelBody({ panel, label, screenId, screenParams, translate, }) {
439
498
  return (_jsx(ProgressListPanelBody, { panel: panel, label: text, screenParams: screenParams, translate: translate }));
440
499
  }
441
500
  const VisibilityQueryContext = createContext(new Map());
442
- // One live query per distinct `visibleWhen.query`, shared by every panel that
443
- // gates on it — N panels on the same status query would otherwise each open
444
- // their own request, SSE subscription and refetch per event.
445
- function VisibilityQueryScope({ query, children, }) {
501
+ const NO_GATE_PAYLOAD = {};
502
+ function visibilityGate(query, payload) {
503
+ return { key: `${query}|${JSON.stringify(payload)}`, query, payload };
504
+ }
505
+ // Embedded screens carry no screen filter; in-grid panels pass the filter
506
+ // (unless ignoreScreenFilter, never for a stat-group) and the time range.
507
+ function panelVisibilityGate(panel, screenParams) {
508
+ if (panel.kind === "custom" || panel.visibleWhen === undefined)
509
+ return undefined;
510
+ if (panel.kind === "screen")
511
+ return visibilityGate(panel.visibleWhen.query, NO_GATE_PAYLOAD);
512
+ const dropsFilter = panel.kind !== "stat-group" && panel.ignoreScreenFilter === true;
513
+ return visibilityGate(panel.visibleWhen.query, {
514
+ ...(dropsFilter ? {} : screenParams.filterParams),
515
+ ...screenParams.rangeParams,
516
+ });
517
+ }
518
+ // One live query per distinct gate (query + payload), shared by every panel
519
+ // that gates on it — N panels on the same status query would otherwise each
520
+ // open their own request, SSE subscription and refetch per event.
521
+ function VisibilityQueryScope({ gate, children, }) {
446
522
  const parent = useContext(VisibilityQueryContext);
447
- const result = useQuery(query, {}, { live: true });
448
- const value = useMemo(() => new Map(parent).set(query, result), [parent, query, result]);
523
+ const result = useQuery(gate.query, gate.payload, {
524
+ live: true,
525
+ });
526
+ const value = useMemo(() => new Map(parent).set(gate.key, result), [parent, gate.key, result]);
449
527
  return (_jsx(VisibilityQueryContext.Provider, { value: value, children: children }));
450
528
  }
451
- function VisibilityQueryScopes({ queries, children, }) {
452
- const [first, ...rest] = queries;
529
+ function VisibilityQueryScopes({ gates, children, }) {
530
+ const [first, ...rest] = gates;
453
531
  if (first === undefined)
454
532
  return children;
455
- return (_jsx(VisibilityQueryScope, { query: first, children: _jsx(VisibilityQueryScopes, { queries: rest, children: children }) }));
533
+ return (_jsx(VisibilityQueryScope, { gate: first, children: _jsx(VisibilityQueryScopes, { gates: rest, children: children }) }));
456
534
  }
457
- function distinctVisibilityQueries(panels) {
458
- const queries = new Set();
535
+ function distinctVisibilityGates(panels, screenParams) {
536
+ const gates = new Map();
459
537
  for (const panel of panels) {
460
- if (panel.kind === "screen" && panel.visibleWhen !== undefined) {
461
- queries.add(panel.visibleWhen.query);
538
+ const gate = panelVisibilityGate(panel, screenParams);
539
+ if (gate !== undefined)
540
+ gates.set(gate.key, gate);
541
+ }
542
+ return [...gates.values()];
543
+ }
544
+ function GatedGridCell({ panel, screenParams, label, className, children, }) {
545
+ const gate = panelVisibilityGate(panel, screenParams);
546
+ const visibility = useContext(VisibilityQueryContext).get(gate?.key ?? "");
547
+ const visibleWhen = panel.kind === "custom" ? undefined : panel.visibleWhen;
548
+ if (visibleWhen !== undefined) {
549
+ const verdict = evalVisibleWhen(visibleWhen, visibility);
550
+ if (verdict === "error" && visibility?.error) {
551
+ return (_jsx("div", { className: className, "data-testid": `dashboard-panel-${panel.id}`, children: _jsx(PanelError, { label: label, error: visibility.error, onRetry: () => void visibility.refetch() }) }));
462
552
  }
553
+ // Loading renders nothing too: a skeleton that then vanishes flickers.
554
+ if (verdict !== "visible")
555
+ return null;
463
556
  }
464
- return [...queries];
557
+ return _jsx("div", { className: className, children: children });
465
558
  }
466
559
  function ScreenPanelTile({ panel, featureName, translate, }) {
467
560
  const visibleWhen = panel.visibleWhen;
468
- const visibility = useContext(VisibilityQueryContext).get(visibleWhen?.query ?? "");
561
+ const visibility = useContext(VisibilityQueryContext).get(visibleWhen !== undefined ? visibilityGate(visibleWhen.query, NO_GATE_PAYLOAD).key : "");
469
562
  const target = useEmbeddedScreen(featureName, panel.screen);
470
563
  if (target === undefined)
471
564
  return null;
@@ -502,7 +595,7 @@ export function WebDashboardBody({ featureName, screen, translate, }) {
502
595
  const { params: rangeParams, control: rangeControl } = useTimeRange(screen, effectiveTranslate);
503
596
  const screenParams = { filterParams, rangeParams };
504
597
  const loadedAtMs = useLoadedAt(screenParams);
505
- const visibilityQueries = distinctVisibilityQueries(screen.panels);
598
+ const visibilityGates = distinctVisibilityGates(screen.panels, screenParams);
506
599
  const scope = screen.scope;
507
600
  // A raw description is agent-facing prose; only an i18n key is user-facing copy.
508
601
  const translatedDescription = screen.description !== undefined ? effectiveTranslate(screen.description) : undefined;
@@ -510,13 +603,13 @@ export function WebDashboardBody({ featureName, screen, translate, }) {
510
603
  const scopeBadge = scope !== undefined ? (_jsx(StatusBadge, { tone: "accent", testId: `dashboard-${screen.id}-scope`, children: effectiveTranslate(scope.badge) })) : null;
511
604
  // The shell header owns the title; the badge sits right after it there.
512
605
  const badgeInHeader = scopeBadge !== null && headerSlotAvailable && PageHeader !== undefined;
513
- 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) => {
606
+ 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, { gates: visibilityGates, children: _jsx("div", { className: "grid gap-4 sm:grid-cols-2 lg:grid-cols-4", children: screen.panels.map((panel) => {
514
607
  if (panel.kind === "screen") {
515
608
  return (_jsx(ScreenPanelTile, { panel: panel, featureName: featureName, translate: effectiveTranslate }, panel.id));
516
609
  }
517
610
  const label = panel.kind === "custom" || panel.label === undefined
518
611
  ? undefined
519
612
  : effectiveTranslate(panel.label);
520
- return (_jsx("div", { className: panelSpanClassName(panel), children: _jsx(PanelBody, { panel: panel, label: label, screenId: screen.id, screenParams: screenParams, translate: effectiveTranslate }) }, panel.id));
613
+ return (_jsx(GatedGridCell, { panel: panel, screenParams: screenParams, label: label ?? panel.id, className: panelSpanClassName(panel), children: _jsx(PanelBody, { panel: panel, label: label, screenId: screen.id, screenParams: screenParams, translate: effectiveTranslate }) }, panel.id));
521
614
  }) }) })] }));
522
615
  }
@@ -468,11 +468,12 @@ export function buildNavRegistrySliceForApp(app, allowedNavQns) {
468
468
  });
469
469
  }
470
470
  }
471
+ const existing = dropNavsWithNonexistentParent(qualified);
471
472
  // Workspace filter: drop nav entries whose qualified id isn't in the
472
473
  // allow-set. A child whose parent gets dropped surfaces as a top-level
473
474
  // entry — the workspace owner should list parents explicitly if they
474
475
  // want the grouping preserved.
475
- const filtered = allowedNavQns !== undefined ? qualified.filter((n) => allowedNavQns.has(n.id)) : qualified;
476
+ const filtered = allowedNavQns !== undefined ? existing.filter((n) => allowedNavQns.has(n.id)) : existing;
476
477
  const allowedQnSet = new Set(filtered.map((n) => n.id));
477
478
  const topLevel = [];
478
479
  const byParentMap = new Map();
@@ -495,6 +496,18 @@ export function buildNavRegistrySliceForApp(app, allowedNavQns) {
495
496
  byParent: (parent) => byParentMap.get(parent) ?? [],
496
497
  };
497
498
  }
499
+ // A parent absent from the (role-projected) schema is not a workspace
500
+ // filter result: the child must not leak to the top level. Transitive.
501
+ function dropNavsWithNonexistentParent(navs) {
502
+ let current = [...navs];
503
+ for (;;) {
504
+ const ids = new Set(current.map((n) => n.id));
505
+ const kept = current.filter((n) => n.parent === undefined || ids.has(n.parent));
506
+ if (kept.length === current.length)
507
+ return current;
508
+ current = kept;
509
+ }
510
+ }
498
511
  // Exported (not part of the package's public index.ts surface) so other
499
512
  // modules under layout/ — e.g. filter-app-schema-navs.ts — qualify nav ids
500
513
  // the same way instead of keeping a second copy in sync.
@@ -6,6 +6,8 @@ export type ComboboxOption = {
6
6
  readonly description?: string;
7
7
  /** Group heading; see `groupOptions` for the ordering rule. */
8
8
  readonly group?: string;
9
+ /** Visible but not choosable. */
10
+ readonly disabled?: boolean;
9
11
  };
10
12
  type ComboboxBaseProps = {
11
13
  readonly id: string;
@@ -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, { "data-testid": `combobox-${id}-option-${opt.value}`, 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, disabled: opt.disabled, onSelect: () => {
109
109
  if (props.multiple === true) {
110
110
  toggleMulti(opt.value);
111
111
  }
@@ -117,7 +117,7 @@ export function ComboboxInput(props) {
117
117
  // No pointer-events toggles on the item: with Tailwind active,
118
118
  // `data-[disabled]:pointer-events-none` silently swallowed mouse
119
119
  // clicks (keyboard select still worked, jsdom tests stayed green).
120
- className: "relative flex cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground", children: [isSelected && (_jsx("span", { className: "absolute left-2 flex h-3.5 w-3.5 items-center justify-center", children: _jsx(Check, { className: "h-4 w-4" }) })), _jsxs("span", { className: "flex min-w-0 flex-col", children: [_jsx("span", { children: opt.label }), opt.description !== undefined && (_jsx("span", { className: "text-xs text-muted-foreground", children: opt.description }))] })] }, opt.value));
120
+ className: cn("relative flex cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground", opt.disabled === true && "cursor-not-allowed opacity-50"), children: [isSelected && (_jsx("span", { className: "absolute left-2 flex h-3.5 w-3.5 items-center justify-center", children: _jsx(Check, { className: "h-4 w-4" }) })), _jsxs("span", { className: "flex min-w-0 flex-col", children: [_jsx("span", { children: opt.label }), opt.description !== undefined && (_jsx("span", { className: "text-xs text-muted-foreground", children: opt.description }))] })] }, opt.value));
121
121
  });
122
122
  return section.heading === undefined ? (items) : (_jsx(Command.Group, { heading: section.heading, className: "[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground", children: items }, section.heading));
123
123
  })] }), onCreate !== undefined && (_jsxs("button", { type: "button", "data-testid": `combobox-${id}-create`, onClick: () => {
@@ -8,8 +8,8 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
8
8
  // date part next to the time input. So `date` and `timestamp` share the
9
9
  // same typing/navigation UX instead of two diverging primitives (#369).
10
10
  import { useTranslation } from "@cosmicdrift/kumiko-renderer";
11
+ import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
11
12
  import { useState } from "react";
12
- import { Temporal } from "temporal-polyfill";
13
13
  import { cn } from "../lib/cn.js";
14
14
  import { CalendarPopover } from "./calendar-popover.js";
15
15
  import { formatDateForInput, formatDatePlaceholder, guessLocale, parseIso, parseTypedDate, toIso, } from "./date-parse.js";
@@ -1,4 +1,4 @@
1
- import { Temporal } from "temporal-polyfill";
1
+ import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
2
2
  export declare function guessLocale(): string;
3
3
  export declare function parseIso(v: string): Temporal.PlainDate | undefined;
4
4
  export declare function toIso(d: Temporal.PlainDate): string;
@@ -4,7 +4,7 @@
4
4
  // zone. TZ/wall-clock conversion for timestamp fields deliberately stays
5
5
  // in timestamp-input.tsx (wire boundary, own test); this file is pure
6
6
  // calendar date.
7
- import { Temporal } from "temporal-polyfill";
7
+ import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
8
8
  // Prefer the native Temporal (Chromium 144+/Firefox 139+) over the bundled
9
9
  // polyfill so `instanceof` checks match values crossing package boundaries;
10
10
  // falls back to the polyfill where native support is absent.
@@ -257,21 +257,30 @@ function SegmentedSelect({ id, name, value, onChange, options, disabled, require
257
257
  onChange(target.value);
258
258
  buttonRefs.current[index]?.focus();
259
259
  };
260
+ // Arrow keys skip disabled segments; with none enabled they stay put.
261
+ const nextEnabledIndex = (from, step) => {
262
+ for (let hop = 1; hop <= options.length; hop++) {
263
+ const candidate = (from + step * hop + options.length * hop) % options.length;
264
+ if (options[candidate]?.disabled !== true)
265
+ return candidate;
266
+ }
267
+ return from;
268
+ };
260
269
  const handleKeyDown = (e, index) => {
261
270
  if (e.key === "ArrowRight" || e.key === "ArrowDown") {
262
271
  e.preventDefault();
263
- selectAt((index + 1) % options.length);
272
+ selectAt(nextEnabledIndex(index, 1));
264
273
  }
265
274
  else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
266
275
  e.preventDefault();
267
- selectAt((index - 1 + options.length) % options.length);
276
+ selectAt(nextEnabledIndex(index, -1));
268
277
  }
269
278
  };
270
279
  return (_jsxs("div", { role: "radiogroup", "aria-labelledby": fieldLabelId(id), "aria-required": required, "aria-invalid": hasError === true ? true : undefined, "data-testid": `segmented-${id}`, className: cn("inline-flex min-h-9 w-fit flex-wrap overflow-hidden rounded-md border", hasError === true ? "border-destructive" : "border-input"), children: [_jsx("input", { type: "hidden", name: name, value: value }), options.map((opt, index) => {
271
280
  const checked = opt.value === value;
272
281
  return (_jsx("button", { ref: (node) => {
273
282
  buttonRefs.current[index] = node;
274
- }, type: "button", role: "radio", "aria-checked": checked, tabIndex: index === tabStopIndex ? 0 : -1, disabled: disabled, "data-testid": `segmented-${id}-${opt.value}`, onClick: () => onChange(opt.value), onKeyDown: (e) => handleKeyDown(e, index), className: cn(
283
+ }, type: "button", role: "radio", "aria-checked": checked, tabIndex: index === tabStopIndex ? 0 : -1, disabled: disabled === true || opt.disabled === true, "data-testid": `segmented-${id}-${opt.value}`, onClick: () => onChange(opt.value), onKeyDown: (e) => handleKeyDown(e, index), className: cn(
275
284
  // Per-segment top/left border instead of the container's
276
285
  // `divide-x`: divide-x only draws verticals between siblings
277
286
  // in source order, which misplaces borders the moment a
@@ -302,7 +311,7 @@ function RadioListSelect({ id, name, value, onChange, options, variant = "list",
302
311
  const descriptionIdOf = (opt) => `${id}-option-${options.findIndex((o) => o.value === opt.value)}-description`;
303
312
  return (_jsx("div", { role: "radiogroup", "aria-labelledby": fieldLabelId(id), "aria-required": required, "aria-invalid": hasError === true ? true : undefined, "data-testid": `radio-list-${id}`, "data-radio-list": "", className: "flex flex-col gap-2", children: groupOptions(options).map((section) => (_jsxs(Fragment, { children: [section.heading !== undefined && (_jsx("div", { "data-testid": `radio-list-${id}-group-${section.heading}`, className: "mt-1 text-xs font-medium text-muted-foreground", children: section.heading })), section.options.map((opt) => (_jsxs("label", { className: card
304
313
  ? RADIO_CARD_CLASS
305
- : "flex items-start gap-2 text-sm font-normal text-foreground", children: [_jsx("input", { type: "radio", name: name, value: opt.value, checked: opt.value === value, disabled: disabled, "aria-describedby": opt.description !== undefined ? descriptionIdOf(opt) : undefined, "data-testid": `radio-list-${id}-${opt.value}`, onChange: () => onChange(opt.value), className: cn("size-4 accent-primary", card ? "mt-0.5 shrink-0" : "mt-0.5") }), _jsxs("span", { className: "flex flex-col gap-0.5", children: [_jsx("span", { className: cn(card && "font-semibold"), children: opt.label }), opt.description !== undefined && (_jsx("span", { id: descriptionIdOf(opt), className: "text-[13px] text-foreground-secondary", children: opt.description }))] })] }, opt.value)))] }, section.heading ?? ""))) }));
314
+ : "flex items-start gap-2 text-sm font-normal text-foreground has-[:disabled]:opacity-50", children: [_jsx("input", { type: "radio", name: name, value: opt.value, checked: opt.value === value, disabled: disabled === true || opt.disabled === true, "aria-describedby": opt.description !== undefined ? descriptionIdOf(opt) : undefined, "data-testid": `radio-list-${id}-${opt.value}`, onChange: () => onChange(opt.value), className: cn("size-4 accent-primary", card ? "mt-0.5 shrink-0" : "mt-0.5") }), _jsxs("span", { className: "flex flex-col gap-0.5", children: [_jsx("span", { className: cn(card && "font-semibold"), children: opt.label }), opt.description !== undefined && (_jsx("span", { id: descriptionIdOf(opt), className: "text-[13px] text-foreground-secondary", children: opt.description }))] })] }, opt.value)))] }, section.heading ?? ""))) }));
306
315
  }
307
316
  // The row count reaches the textarea through an untyped view-model hint, so a
308
317
  // sloppy schema can hand over a fraction or a zero — neither renders a sane
@@ -318,6 +327,16 @@ function normalizedTextareaRows(rows) {
318
327
  function textareaMinHeight(rows) {
319
328
  return { minHeight: `calc(${rows} * 1lh + 1rem + 2px)` };
320
329
  }
330
+ function withInlineDisabledHint(option) {
331
+ if (option.disabled !== true || option.description === undefined)
332
+ return option;
333
+ return {
334
+ value: option.value,
335
+ label: `${option.label} (${option.description})`,
336
+ disabled: true,
337
+ ...(option.group !== undefined && { group: option.group }),
338
+ };
339
+ }
321
340
  function DefaultInput(props) {
322
341
  // Vendored ui/input + ui/checkbox stylen Fehler über `aria-invalid`
323
342
  // selbst — kein manuelles border-destructive mehr nötig.
@@ -365,6 +384,8 @@ function DefaultInput(props) {
365
384
  // 4-Item-Status-Selects vorhanden, das ist eine bewusst akzeptierte
366
385
  // UX-Konsequenz für Style-Konsistenz.
367
386
  const comboOptions = props.options.map((o) => typeof o === "string" ? { value: o, label: o } : o);
387
+ // The dropdown shows a disabled option's hint inline, so it stays readable in the trigger and search.
388
+ const dropdownOptions = comboOptions.map(withInlineDisabledHint);
368
389
  // The radio group can't render the unselected placeholder as its own
369
390
  // segment, so it counts and renders on the real options only — the
370
391
  // dropdown below keeps comboOptions (placeholder included) since it
@@ -392,7 +413,7 @@ function DefaultInput(props) {
392
413
  if (presentation === "segmented" && radioGroupOptions.length > 0) {
393
414
  return (_jsx(SegmentedSelect, { id: props.id, name: props.name, value: props.value, onChange: props.onChange, options: radioGroupOptions, ...(props.disabled !== undefined && { disabled: props.disabled }), ...(props.required !== undefined && { required: props.required }), ...(props.hasError !== undefined && { hasError: props.hasError }) }));
394
415
  }
395
- return (_jsx(ComboboxInput, { id: props.id, name: props.name, value: props.value, onChange: props.onChange, options: comboOptions, ...(props.disabled !== undefined && { disabled: props.disabled }), ...(props.required !== undefined && { required: props.required }), ...(props.hasError !== undefined && { hasError: props.hasError }) }));
416
+ return (_jsx(ComboboxInput, { id: props.id, name: props.name, value: props.value, onChange: props.onChange, options: dropdownOptions, ...(props.disabled !== undefined && { disabled: props.disabled }), ...(props.required !== undefined && { required: props.required }), ...(props.hasError !== undefined && { hasError: props.hasError }) }));
396
417
  }
397
418
  case "combobox": {
398
419
  // Tier 2.1c + Tier 2.7e: Discriminated-Union per `multiple` —
@@ -1828,7 +1849,10 @@ export function DefaultCard({ slots, options, className, testId, dataAttributes,
1828
1849
  s.headerActions !== undefined ? (_jsxs("div", { className: cn("flex flex-col gap-3 px-[var(--card-padding)] pt-6 pb-4", fillHeight && "shrink-0"), children: [(s.title !== undefined || s.subtitle !== undefined || s.headerActions !== undefined) && (_jsxs("div", { className: "flex flex-wrap items-start justify-between gap-3", children: [_jsxs("div", { className: "flex flex-col gap-1", children: [s.title !== undefined && (_jsx("h3", { className: "text-base font-semibold leading-none tracking-tight", children: s.title })), s.subtitle !== undefined && (_jsx("p", { className: "text-sm text-muted-foreground", children: s.subtitle }))] }), s.headerActions] })), s.headerContent] })) : null;
1829
1850
  const header = s.header ?? defaultHeader;
1830
1851
  const hasHeader = header !== null && header !== undefined;
1831
- const card = (_jsxs("div", { ...dataAttributes, "data-slot": "card", "data-testid": testId, className: cn(framed ? cardSurface({ radius }) : "flex flex-col", "overflow-hidden",
1852
+ const card = (_jsxs("div", { ...dataAttributes, "data-slot": "card", "data-testid": testId, className: cn(framed ? cardSurface({ radius }) : "flex flex-col",
1853
+ // An unframed card has no radius to clip; overflow-hidden would also
1854
+ // zero its flex-item min-height and cut off content inside a scroller.
1855
+ (framed || fillHeight) && "overflow-hidden",
1832
1856
  // Same "no flex-1" reasoning as DefaultForm's own fillHeight card
1833
1857
  // (fw#2722/#2778): sizes to content and only shrinks (min-h-0) once
1834
1858
  // an ancestor is itself height-constrained.
@@ -87,6 +87,7 @@ export declare function TimeseriesChart({ points, windowStartMs, windowEndMs, to
87
87
  readonly testId?: string;
88
88
  }): ReactNode;
89
89
  export type ChartTone = "positive" | "negative" | "active" | "neutral";
90
+ export declare function chartToneColor(tone: ChartTone): string;
90
91
  export type ChartSeries = {
91
92
  readonly key: string;
92
93
  /** Translated by the caller. */
@@ -96,6 +97,12 @@ export type ChartSeries = {
96
97
  export type ChartMarker = {
97
98
  readonly atMs: number;
98
99
  readonly label: string;
100
+ /** Colors the pin and, on StackedAreaChart, draws a dashed guide line in the plot. */
101
+ readonly color?: string;
102
+ };
103
+ /** Unstacked line drawn over a StackedAreaChart on the same y scale. */
104
+ export type ChartLine = ChartSeries & {
105
+ readonly dashed?: boolean;
99
106
  };
100
107
  export type SegmentBarRow = {
101
108
  readonly key: string;
@@ -108,13 +115,16 @@ export type SegmentBarRow = {
108
115
  }[];
109
116
  };
110
117
  type ToneMap = Readonly<Record<string, ChartTone>>;
118
+ /** Key -> raw CSS color; wins over the tone map. */
119
+ type ColorMap = Readonly<Record<string, string>>;
111
120
  /** Vertically stacked bars per bucket (x axis = bucket, not time).
112
121
  * The last bucket carries `todayLabel` when it contains `windowEndMs` and
113
122
  * has day granularity. */
114
- export declare function StackedBarChart({ series, windowEndMs, tones, ariaLabel, todayLabel, formatBucketLabel, formatValue, emptyContent, testId, }: {
123
+ export declare function StackedBarChart({ series, windowEndMs, tones, colors, ariaLabel, todayLabel, formatBucketLabel, formatValue, emptyContent, testId, }: {
115
124
  readonly series: readonly ChartSeries[];
116
125
  readonly windowEndMs: number;
117
126
  readonly tones?: ToneMap;
127
+ readonly colors?: ColorMap;
118
128
  readonly ariaLabel: string;
119
129
  readonly todayLabel: string;
120
130
  readonly formatBucketLabel: (atMs: number) => string;
@@ -124,22 +134,26 @@ export declare function StackedBarChart({ series, windowEndMs, tones, ariaLabel,
124
134
  }): ReactNode;
125
135
  /** One horizontally stacked bar per row; bar length is relative to the
126
136
  * largest row so rows stay comparable. */
127
- export declare function SegmentBarChart({ rows, tones, ariaLabel, formatValue, testId, }: {
137
+ export declare function SegmentBarChart({ rows, tones, colors, ariaLabel, formatValue, testId, }: {
128
138
  readonly rows: readonly SegmentBarRow[];
129
139
  readonly tones?: ToneMap;
140
+ readonly colors?: ColorMap;
130
141
  readonly ariaLabel: string;
131
142
  readonly formatValue?: (value: number) => string;
132
143
  readonly testId?: string;
133
144
  }): ReactNode;
134
145
  /** Stacked bands over time. Right of `todayMs` is the forecast: lighter
135
146
  * fill plus a vertical "today" line. */
136
- export declare function StackedAreaChart({ series, windowStartMs, windowEndMs, todayMs, tones, markers, ariaLabel, todayLabel, formatBucketLabel, formatValue, formatMarkerTime, scrollable, emptyContent, testId, }: {
147
+ export declare function StackedAreaChart({ series, windowStartMs, windowEndMs, todayMs, tones, colors, markers, lines, ariaLabel, todayLabel, formatBucketLabel, formatValue, formatMarkerTime, scrollable, showLegendTotals, emptyContent, testId, }: {
137
148
  readonly series: readonly ChartSeries[];
138
149
  readonly windowStartMs: number;
139
150
  readonly windowEndMs: number;
140
151
  readonly todayMs?: number;
141
152
  readonly tones?: ToneMap;
153
+ readonly colors?: ColorMap;
142
154
  readonly markers?: readonly ChartMarker[];
155
+ /** Drawn over the bands, not stacked. A `null` value breaks the line. */
156
+ readonly lines?: readonly ChartLine[];
143
157
  readonly ariaLabel: string;
144
158
  readonly todayLabel: string;
145
159
  readonly formatBucketLabel: (atMs: number) => string;
@@ -147,6 +161,7 @@ export declare function StackedAreaChart({ series, windowStartMs, windowEndMs, t
147
161
  readonly formatMarkerTime?: (atMs: number) => string;
148
162
  /** Fixed width per bucket; the plot scrolls horizontally, y ticks stay put. */
149
163
  readonly scrollable?: boolean;
164
+ readonly showLegendTotals?: boolean;
150
165
  readonly emptyContent?: ReactNode;
151
166
  readonly testId?: string;
152
167
  }): ReactNode;
@@ -130,8 +130,14 @@ const CHART_TONE_VAR = {
130
130
  active: "var(--color-status-active)",
131
131
  neutral: "var(--color-status-neutral)",
132
132
  };
133
+ export function chartToneColor(tone) {
134
+ return CHART_TONE_VAR[tone];
135
+ }
133
136
  const CHART_FALLBACK_TONES = ["active", "positive", "negative", "neutral"];
134
- function chartColor(key, index, tones) {
137
+ function chartColor(key, index, tones, colors) {
138
+ const color = colors?.[key];
139
+ if (color !== undefined)
140
+ return color;
135
141
  const tone = tones?.[key] ?? CHART_FALLBACK_TONES[index % CHART_FALLBACK_TONES.length] ?? "active";
136
142
  return CHART_TONE_VAR[tone];
137
143
  }
@@ -159,6 +165,19 @@ function bucketTimes(series) {
159
165
  function valueAt(series, atMs) {
160
166
  return series.points.find((p) => p.atMs === atMs)?.value ?? 0;
161
167
  }
168
+ function linePathSegments(line, xOf, yOf) {
169
+ const commands = [];
170
+ let penDown = false;
171
+ for (const point of [...line.points].sort((a, b) => a.atMs - b.atMs)) {
172
+ if (point.value === null) {
173
+ penDown = false;
174
+ continue;
175
+ }
176
+ commands.push(`${penDown ? "L" : "M"} ${xOf(point.atMs).toFixed(1)} ${yOf(point.value).toFixed(1)}`);
177
+ penDown = true;
178
+ }
179
+ return commands.join(" ");
180
+ }
162
181
  function seriesTotal(series) {
163
182
  return series.points.reduce((sum, p) => sum + (p.value ?? 0), 0);
164
183
  }
@@ -166,7 +185,10 @@ function percent(fraction) {
166
185
  return `${(fraction * 100).toFixed(2)}%`;
167
186
  }
168
187
  function ChartLegend({ items, formatValue, }) {
169
- return (_jsx("ul", { className: "flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground", children: items.map((item) => (_jsxs("li", { "data-testid": `chart-legend-${item.key}`, className: "flex items-center gap-1.5", children: [_jsx("span", { "aria-hidden": "true", className: "size-2 rounded-[2px]", style: { backgroundColor: item.color } }), _jsx("span", { children: item.label }), _jsx("span", { className: "tabular-nums text-foreground", children: formatValue(item.total) })] }, item.key))) }));
188
+ return (_jsx("ul", { className: "flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground", children: items.map((item) => (_jsxs("li", { "data-testid": `chart-legend-${item.key}`, className: "flex items-center gap-1.5", children: [item.swatch === undefined || item.swatch === "area" ? (_jsx("span", { "aria-hidden": "true", className: "size-2 rounded-[2px]", style: { backgroundColor: item.color } })) : (_jsx("span", { "aria-hidden": "true", className: "w-3 border-t-2", style: {
189
+ borderColor: item.color,
190
+ borderTopStyle: item.swatch === "dashed-line" ? "dashed" : "solid",
191
+ } })), _jsx("span", { children: item.label }), item.total !== undefined && (_jsx("span", { className: "tabular-nums text-foreground", children: formatValue(item.total) }))] }, item.key))) }));
170
192
  }
171
193
  function YTicks({ max, formatValue, className, }) {
172
194
  return (_jsx("div", { "aria-hidden": "true", className: cn("flex flex-col-reverse justify-between text-right text-[11px] tabular-nums text-muted-foreground", className), children: Array.from({ length: Y_TICK_COUNT }, (_, i) => (_jsx("span", { children: formatValue((max / (Y_TICK_COUNT - 1)) * i) }, i))) }));
@@ -175,7 +197,7 @@ const BAR_LABEL_TARGET = 8;
175
197
  /** Vertically stacked bars per bucket (x axis = bucket, not time).
176
198
  * The last bucket carries `todayLabel` when it contains `windowEndMs` and
177
199
  * has day granularity. */
178
- export function StackedBarChart({ series, windowEndMs, tones, ariaLabel, todayLabel, formatBucketLabel, formatValue = String, emptyContent, testId, }) {
200
+ export function StackedBarChart({ series, windowEndMs, tones, colors, ariaLabel, todayLabel, formatBucketLabel, formatValue = String, emptyContent, testId, }) {
179
201
  const times = bucketTimes(series);
180
202
  if (times.length === 0)
181
203
  return _jsx("div", { "data-testid": testId, children: emptyContent });
@@ -195,19 +217,19 @@ export function StackedBarChart({ series, windowEndMs, tones, ariaLabel, todayLa
195
217
  return null;
196
218
  return (_jsx("div", { "data-testid": `chart-bar-segment-${s.key}`, style: {
197
219
  height: percent(value / (totals[i] ?? 1)),
198
- backgroundColor: chartColor(s.key, si, tones),
220
+ backgroundColor: chartColor(s.key, si, tones, colors),
199
221
  } }, s.key));
200
222
  }) }) }), _jsx("div", { className: "h-5 pt-1 text-center text-[11px] leading-4 whitespace-nowrap text-muted-foreground", children: (lastIdx - i) % labelEvery === 0 ? label : "" })] }, atMs));
201
223
  }) })] }), _jsx(ChartLegend, { formatValue: formatValue, items: series.map((s, i) => ({
202
224
  key: s.key,
203
225
  label: s.label,
204
- color: chartColor(s.key, i, tones),
226
+ color: chartColor(s.key, i, tones, colors),
205
227
  total: seriesTotal(s),
206
228
  })) })] }));
207
229
  }
208
230
  /** One horizontally stacked bar per row; bar length is relative to the
209
231
  * largest row so rows stay comparable. */
210
- export function SegmentBarChart({ rows, tones, ariaLabel, formatValue = String, testId, }) {
232
+ export function SegmentBarChart({ rows, tones, colors, ariaLabel, formatValue = String, testId, }) {
211
233
  const rowTotal = (row) => row.segments.length > 0 ? row.segments.reduce((sum, s) => sum + s.value, 0) : row.value;
212
234
  const maxTotal = Math.max(1, ...rows.map(rowTotal));
213
235
  const segmentTotals = new Map();
@@ -223,23 +245,35 @@ export function SegmentBarChart({ rows, tones, ariaLabel, formatValue = String,
223
245
  const legendKeys = [...segmentTotals.keys()];
224
246
  return (_jsxs("div", { "data-testid": testId, role: "img", "aria-label": ariaLabel, className: "flex flex-col gap-3", children: [_jsx("ul", { className: "flex flex-col gap-3", children: rows.map((row) => (_jsxs("li", { "data-testid": `chart-row-${row.key}`, className: "flex flex-col gap-1", children: [_jsxs("div", { className: "flex items-baseline justify-between gap-2 text-sm", children: [_jsx("span", { className: "font-medium", children: row.label }), _jsx("span", { className: "tabular-nums text-muted-foreground", children: formatValue(row.value) })] }), _jsx("div", { className: "flex h-2 gap-px overflow-hidden rounded-full bg-muted", children: row.segments.map((seg) => (_jsx("div", { title: `${seg.label}: ${formatValue(seg.value)}`, className: "h-full", style: {
225
247
  width: percent(seg.value / maxTotal),
226
- backgroundColor: chartColor(seg.key, legendKeys.indexOf(seg.key), tones),
248
+ backgroundColor: chartColor(seg.key, legendKeys.indexOf(seg.key), tones, colors),
227
249
  } }, seg.key))) })] }, row.key))) }), _jsx(ChartLegend, { formatValue: formatValue, items: legendKeys.map((key, i) => ({
228
250
  key,
229
251
  label: segmentTotals.get(key)?.label ?? key,
230
- color: chartColor(key, i, tones),
252
+ color: chartColor(key, i, tones, colors),
231
253
  total: segmentTotals.get(key)?.total ?? 0,
232
254
  })) })] }));
233
255
  }
256
+ // Two markers may share atMs and label, so the occurrence count keeps React keys unique.
234
257
  function sortMarkers(markers) {
235
- return [...markers].sort((a, b) => a.atMs - b.atMs);
258
+ const seen = new Map();
259
+ return [...markers]
260
+ .sort((a, b) => a.atMs - b.atMs)
261
+ .map((marker) => {
262
+ const base = `${marker.atMs}-${marker.label}`;
263
+ const occurrence = seen.get(base) ?? 0;
264
+ seen.set(base, occurrence + 1);
265
+ return { ...marker, key: `${base}-${occurrence}` };
266
+ });
236
267
  }
237
268
  function MarkerPins({ markers, windowStartMs, windowEndMs, }) {
238
269
  const span = Math.max(1, windowEndMs - windowStartMs);
239
- return (_jsx("div", { "aria-hidden": "true", className: "relative mt-1 h-5", children: sortMarkers(markers).map((marker, i) => (_jsx("span", { "data-testid": "chart-marker-pin", className: "absolute flex size-4 -translate-x-1/2 items-center justify-center rounded-full bg-foreground text-[10px] font-medium text-background", style: { left: percent(Math.max(0, Math.min(1, (marker.atMs - windowStartMs) / span))) }, children: i + 1 }, `${marker.atMs}-${marker.label}`))) }));
270
+ return (_jsx("div", { "aria-hidden": "true", className: "relative mt-1 h-5", children: sortMarkers(markers).map((marker, i) => (_jsx("span", { "data-testid": "chart-marker-pin", className: cn("absolute flex size-4 -translate-x-1/2 items-center justify-center rounded-full text-[10px] font-medium text-background", marker.color === undefined && "bg-foreground"), style: {
271
+ left: percent(Math.max(0, Math.min(1, (marker.atMs - windowStartMs) / span))),
272
+ ...(marker.color !== undefined && { backgroundColor: marker.color }),
273
+ }, children: i + 1 }, marker.key))) }));
240
274
  }
241
275
  function MarkerLegend({ markers, formatMarkerTime, }) {
242
- return (_jsx("ol", { className: "flex flex-col gap-1 text-xs text-muted-foreground", children: sortMarkers(markers).map((marker, i) => (_jsxs("li", { "data-testid": "chart-marker-item", className: "flex gap-2", children: [_jsx("span", { className: "w-4 shrink-0 text-center tabular-nums text-foreground", children: i + 1 }), _jsx("span", { children: marker.label }), formatMarkerTime !== undefined && (_jsx("span", { className: "ml-auto tabular-nums", children: formatMarkerTime(marker.atMs) }))] }, `${marker.atMs}-${marker.label}`))) }));
276
+ return (_jsx("ol", { className: "flex flex-col gap-1 text-xs text-muted-foreground", children: sortMarkers(markers).map((marker, i) => (_jsxs("li", { "data-testid": "chart-marker-item", className: "flex gap-2", children: [_jsx("span", { className: "w-4 shrink-0 text-center tabular-nums text-foreground", style: marker.color !== undefined ? { color: marker.color } : undefined, children: i + 1 }), _jsx("span", { children: marker.label }), formatMarkerTime !== undefined && (_jsx("span", { className: "ml-auto tabular-nums", children: formatMarkerTime(marker.atMs) }))] }, marker.key))) }));
243
277
  }
244
278
  const SCROLLABLE_BUCKET_WIDTH_PX = 48;
245
279
  function bucketLabelTransform(index, count) {
@@ -249,10 +283,10 @@ function bucketLabelTransform(index, count) {
249
283
  }
250
284
  /** Stacked bands over time. Right of `todayMs` is the forecast: lighter
251
285
  * fill plus a vertical "today" line. */
252
- export function StackedAreaChart({ series, windowStartMs, windowEndMs, todayMs, tones, markers, ariaLabel, todayLabel, formatBucketLabel, formatValue = String, formatMarkerTime, scrollable = false, emptyContent, testId, }) {
286
+ export function StackedAreaChart({ series, windowStartMs, windowEndMs, todayMs, tones, colors, markers, lines = [], ariaLabel, todayLabel, formatBucketLabel, formatValue = String, formatMarkerTime, scrollable = false, showLegendTotals = true, emptyContent, testId, }) {
253
287
  const clipId = useId();
254
288
  const scrollRef = useRef(null);
255
- const times = bucketTimes(series);
289
+ const times = bucketTimes([...series, ...lines]);
256
290
  const bucketCount = times.length;
257
291
  const span = Math.max(1, windowEndMs - windowStartMs);
258
292
  const fractionOf = (atMs) => Math.max(0, Math.min(1, (atMs - windowStartMs) / span));
@@ -271,9 +305,10 @@ export function StackedAreaChart({ series, windowStartMs, windowEndMs, todayMs,
271
305
  const width = 300;
272
306
  const height = 120;
273
307
  const totals = times.map((atMs) => series.reduce((sum, s) => sum + valueAt(s, atMs), 0));
308
+ const lineValues = lines.flatMap((line) => line.points.flatMap((p) => (p.value === null ? [] : [p.value])));
274
309
  const isSingleBucket = times.length === 1;
275
310
  // Doubling keeps a lone bucket away from the top edge of the y-scale.
276
- const max = niceCeil(Math.max(...totals) * (isSingleBucket ? 2 : 1));
311
+ const max = niceCeil(Math.max(...totals, ...lineValues) * (isSingleBucket ? 2 : 1));
277
312
  const yOf = (value) => height - (value / max) * height;
278
313
  const lower = times.map(() => 0);
279
314
  const bands = series.map((s, si) => {
@@ -288,21 +323,41 @@ export function StackedAreaChart({ series, windowStartMs, windowEndMs, todayMs,
288
323
  lower[ti] = upper[ti] ?? 0;
289
324
  return {
290
325
  key: s.key,
291
- color: chartColor(s.key, si, tones),
326
+ color: chartColor(s.key, si, tones, colors),
292
327
  d: `M ${top.join(" L ")} L ${bottom.join(" L ")} Z`,
293
328
  dot: isSingleBucket && bucketHeight > 0 ? clampDotY(yOf(bucketTop), height) : undefined,
294
329
  };
295
330
  });
331
+ const lineColorOffset = series.length;
332
+ const linePaths = lines.map((line, li) => ({
333
+ key: line.key,
334
+ label: line.label,
335
+ dashed: line.dashed === true,
336
+ color: chartColor(line.key, lineColorOffset + li, tones, colors),
337
+ d: linePathSegments(line, (atMs) => fractionOf(atMs) * width, yOf),
338
+ }));
339
+ const coloredMarkers = sortMarkers(markers ?? []).filter((marker) => marker.color !== undefined);
296
340
  const todayX = (todayFraction ?? 1) * width;
297
341
  const renderBands = (fillOpacity) => bands.map((band) => band.dot === undefined ? (_jsx("path", { d: band.d, fill: band.color, fillOpacity: fillOpacity, stroke: "none" }, band.key)) : (_jsx("circle", { "data-testid": "stacked-area-single-point", cx: width / 2, cy: band.dot, r: SINGLE_POINT_RADIUS, fill: band.color, fillOpacity: fillOpacity }, band.key)));
298
- const plot = (_jsxs(_Fragment, { children: [todayFraction !== undefined && (_jsx("span", { "data-testid": "chart-today-label", className: "absolute -top-4 -translate-x-1/2 text-[11px] text-foreground", style: { left: percent(todayFraction) }, children: todayLabel })), _jsxs("svg", { viewBox: `0 0 ${width} ${height}`, preserveAspectRatio: "none", className: "block h-32 w-full", role: "img", "aria-label": ariaLabel, children: [_jsx("title", { children: ariaLabel }), _jsxs("defs", { children: [_jsx("clipPath", { id: `${clipId}-past`, children: _jsx("rect", { x: 0, y: 0, width: todayX, height: height }) }), _jsx("clipPath", { id: `${clipId}-future`, children: _jsx("rect", { x: todayX, y: 0, width: width - todayX, height: height }) })] }), _jsx("g", { clipPath: `url(#${clipId}-past)`, children: renderBands(0.85) }), todayFraction !== undefined && (_jsx("g", { "data-testid": "chart-forecast-region", clipPath: `url(#${clipId}-future)`, children: renderBands(0.3) })), todayFraction !== undefined && (_jsx("line", { "data-testid": "chart-today-line", x1: todayX, x2: todayX, y1: 0, y2: height, stroke: "var(--color-foreground)", strokeWidth: 1, strokeDasharray: "3 3", vectorEffect: "non-scaling-stroke" }))] }), scrollable ? (_jsx("div", { "data-testid": "chart-bucket-labels", className: "relative mt-1 h-4 text-[11px] text-muted-foreground", children: times.map((atMs, i) => (_jsx("span", { className: cn("absolute whitespace-nowrap", bucketLabelTransform(i, times.length)), style: { left: percent(fractionOf(atMs)) }, children: formatBucketLabel(atMs) }, atMs))) })) : (_jsxs("div", { className: "mt-1 flex justify-between text-[11px] text-muted-foreground", children: [_jsx("span", { children: formatBucketLabel(windowStartMs) }), _jsx("span", { children: formatBucketLabel(windowEndMs) })] })), markers !== undefined && markers.length > 0 && (_jsx(MarkerPins, { markers: markers, windowStartMs: windowStartMs, windowEndMs: windowEndMs }))] }));
342
+ const plot = (_jsxs(_Fragment, { children: [todayFraction !== undefined && (_jsx("span", { "data-testid": "chart-today-label", className: "absolute -top-4 -translate-x-1/2 text-[11px] text-foreground", style: { left: percent(todayFraction) }, children: todayLabel })), _jsxs("svg", { viewBox: `0 0 ${width} ${height}`, preserveAspectRatio: "none", className: "block h-32 w-full", role: "img", "aria-label": ariaLabel, children: [_jsx("title", { children: ariaLabel }), _jsxs("defs", { children: [_jsx("clipPath", { id: `${clipId}-past`, children: _jsx("rect", { x: 0, y: 0, width: todayX, height: height }) }), _jsx("clipPath", { id: `${clipId}-future`, children: _jsx("rect", { x: todayX, y: 0, width: width - todayX, height: height }) })] }), _jsx("g", { clipPath: `url(#${clipId}-past)`, children: renderBands(0.85) }), todayFraction !== undefined && (_jsx("g", { "data-testid": "chart-forecast-region", clipPath: `url(#${clipId}-future)`, children: renderBands(0.3) })), coloredMarkers.map((marker) => {
343
+ const x = fractionOf(marker.atMs) * width;
344
+ return (_jsx("line", { "data-testid": "chart-marker-guide", x1: x, x2: x, y1: 0, y2: height, stroke: marker.color, strokeWidth: 1, strokeDasharray: "2 3", vectorEffect: "non-scaling-stroke" }, marker.key));
345
+ }), linePaths.map((line) => (_jsx("path", { "data-testid": `chart-line-${line.key}`, d: line.d, fill: "none", stroke: line.color, strokeWidth: 2, strokeDasharray: line.dashed ? "6 4" : undefined, vectorEffect: "non-scaling-stroke" }, line.key))), todayFraction !== undefined && (_jsx("line", { "data-testid": "chart-today-line", x1: todayX, x2: todayX, y1: 0, y2: height, stroke: "var(--color-foreground)", strokeWidth: 1, strokeDasharray: "3 3", vectorEffect: "non-scaling-stroke" }))] }), scrollable ? (_jsx("div", { "data-testid": "chart-bucket-labels", className: "relative mt-1 h-4 text-[11px] text-muted-foreground", children: times.map((atMs, i) => (_jsx("span", { className: cn("absolute whitespace-nowrap", bucketLabelTransform(i, times.length)), style: { left: percent(fractionOf(atMs)) }, children: formatBucketLabel(atMs) }, atMs))) })) : (_jsxs("div", { className: "mt-1 flex justify-between text-[11px] text-muted-foreground", children: [_jsx("span", { children: formatBucketLabel(windowStartMs) }), _jsx("span", { children: formatBucketLabel(windowEndMs) })] })), markers !== undefined && markers.length > 0 && (_jsx(MarkerPins, { markers: markers, windowStartMs: windowStartMs, windowEndMs: windowEndMs }))] }));
299
346
  return (_jsxs("div", { "data-testid": testId, className: "flex flex-col gap-3", children: [_jsxs("div", { className: "flex gap-2", children: [_jsx(YTicks, { max: max, formatValue: formatValue, className: cn("h-32 pb-0", scrollable && "mt-4") }), scrollable ? (_jsx("div", { ref: scrollRef, "data-testid": "chart-scroll-container", className: "grow overflow-x-auto pt-4", children: _jsx("div", { "data-testid": "chart-scroll-content", className: "relative", style: {
300
347
  minWidth: "100%",
301
348
  width: `${times.length * SCROLLABLE_BUCKET_WIDTH_PX}px`,
302
- }, children: plot }) })) : (_jsx("div", { className: "relative grow", children: plot }))] }), _jsx(ChartLegend, { formatValue: formatValue, items: series.map((s, i) => ({
303
- key: s.key,
304
- label: s.label,
305
- color: chartColor(s.key, i, tones),
306
- total: seriesTotal(s),
307
- })) }), markers !== undefined && markers.length > 0 && (_jsx(MarkerLegend, { markers: markers, formatMarkerTime: formatMarkerTime }))] }));
349
+ }, children: plot }) })) : (_jsx("div", { className: "relative grow", children: plot }))] }), _jsx(ChartLegend, { formatValue: formatValue, items: [
350
+ ...series.map((s, i) => ({
351
+ key: s.key,
352
+ label: s.label,
353
+ color: chartColor(s.key, i, tones, colors),
354
+ ...(showLegendTotals && { total: seriesTotal(s) }),
355
+ })),
356
+ ...linePaths.map((line) => ({
357
+ key: line.key,
358
+ label: line.label,
359
+ color: line.color,
360
+ swatch: line.dashed ? "dashed-line" : "line",
361
+ })),
362
+ ] }), markers !== undefined && markers.length > 0 && (_jsx(MarkerLegend, { markers: markers, formatMarkerTime: formatMarkerTime }))] }));
308
363
  }
@@ -4,6 +4,8 @@ export type ProgressListRow = {
4
4
  readonly label: string;
5
5
  readonly value: string;
6
6
  readonly fraction: number;
7
+ /** Small line under the bar, e.g. "42 % repaid". */
8
+ readonly sub?: string;
7
9
  };
8
10
  /** Liste aus Label/Wert-Kopfzeile + Fortschrittsbalken pro Eintrag (z.B.
9
11
  * Tilgungsfortschritt pro Kredit). */
@@ -6,5 +6,5 @@ export function ProgressList({ rows, emptyContent, testId, }) {
6
6
  if (rows.length === 0) {
7
7
  return _jsx("div", { "data-testid": testId, children: emptyContent });
8
8
  }
9
- return (_jsx("ul", { "data-testid": testId, className: "flex max-h-64 flex-col gap-3 overflow-y-auto pr-1", children: rows.map((row) => (_jsxs("li", { className: "flex flex-col gap-1", children: [_jsxs("div", { className: "flex items-baseline justify-between gap-2 text-sm", children: [_jsx("span", { className: "font-medium", children: row.label }), _jsx("span", { className: "tabular-nums text-muted-foreground", children: row.value })] }), _jsx(ProgressBar, { value: row.fraction })] }, row.id))) }));
9
+ return (_jsx("ul", { "data-testid": testId, className: "flex max-h-64 flex-col gap-3 overflow-y-auto pr-1", children: rows.map((row) => (_jsxs("li", { className: "flex flex-col gap-1", children: [_jsxs("div", { className: "flex items-baseline justify-between gap-2 text-sm", children: [_jsx("span", { className: "font-medium", children: row.label }), _jsx("span", { className: "tabular-nums text-muted-foreground", children: row.value })] }), _jsx(ProgressBar, { value: row.fraction }), row.sub !== undefined && _jsx("div", { className: "text-xs text-muted-foreground", children: row.sub })] }, row.id))) }));
10
10
  }
@@ -28,11 +28,14 @@ export declare function StatCard({ icon, label, value, sub, tone, accentColor, d
28
28
  }): ReactNode;
29
29
  /** Flat KPI cell for a strip (divider instead of card): label, value with
30
30
  * sparkline beside it, sub line. */
31
- export declare function StatStripCell({ label, value, sub, tone, delta, spark, testId, }: {
31
+ export declare function StatStripCell({ icon, label, value, sub, tone, accentColor, delta, spark, testId, }: {
32
+ /** Chip before the label, colored like StatCard's (`accentColor` or the tone). */
33
+ readonly icon?: ReactNode;
32
34
  readonly label: string;
33
35
  readonly value: string;
34
36
  readonly sub?: string;
35
37
  readonly tone?: StatTone;
38
+ readonly accentColor?: string;
36
39
  readonly delta?: StatDelta;
37
40
  readonly spark?: readonly number[];
38
41
  readonly testId?: string;
@@ -32,6 +32,14 @@ export function Sparkline({ points, className, }) {
32
32
  .join(" ");
33
33
  return (_jsxs("svg", { viewBox: `0 0 ${w} ${h}`, preserveAspectRatio: "none", className: className ?? "h-7 w-full", "aria-hidden": "true", children: [_jsx("path", { d: `${line} L${w},${h} L0,${h} Z`, fill: "currentColor", fillOpacity: 0.12 }), _jsx("path", { d: line, fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", vectorEffect: "non-scaling-stroke" })] }));
34
34
  }
35
+ function StatIconChip({ icon, tone, accentColor, }) {
36
+ return (_jsx("span", { className: cn("flex size-7 shrink-0 items-center justify-center rounded-lg", accentColor === undefined && TONE_CHIP[tone]), style: accentColor === undefined
37
+ ? undefined
38
+ : {
39
+ color: accentColor,
40
+ backgroundColor: `color-mix(in srgb, ${accentColor} 12%, transparent)`,
41
+ }, children: icon }));
42
+ }
35
43
  /** Icon-tragende Kennzahl-Kachel mit optionalem Delta-Chip, Trend-Zeile
36
44
  * und Sparkline. `icon` ist ein fertiger Knoten (App liefert ihr SVG). */
37
45
  export function StatCard({ icon, label, value, sub, tone = "default", accentColor, delta, trend, spark, testId, children, }) {
@@ -40,17 +48,12 @@ export function StatCard({ icon, label, value, sub, tone = "default", accentColo
40
48
  // a whole-section frame) — an extension section mounting several of
41
49
  // these renders a grid of tiles inside its own host Card, not a
42
50
  // duplicate outer frame around the section's full content.
43
- 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("span", { className: cn("flex size-7 items-center justify-center rounded-lg", accentColor === undefined && TONE_CHIP[tone]), style: accentColor === undefined
44
- ? undefined
45
- : {
46
- color: accentColor,
47
- backgroundColor: `color-mix(in srgb, ${accentColor} 12%, transparent)`,
48
- }, children: icon })), _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 })] }));
51
+ 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 })] }));
49
52
  }
50
53
  /** Flat KPI cell for a strip (divider instead of card): label, value with
51
54
  * sparkline beside it, sub line. */
52
- export function StatStripCell({ label, value, sub, tone = "default", delta, spark, testId, }) {
53
- return (_jsxs("div", { "data-testid": testId, className: "flex min-w-0 flex-col gap-1 px-4 py-3", children: [_jsxs("div", { className: "flex items-center justify-between gap-2", children: [_jsx("span", { className: "text-xs font-medium text-muted-foreground", children: label }), delta !== undefined && (_jsxs("span", { className: cn("inline-flex items-center gap-0.5 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 })] }));
55
+ export function StatStripCell({ icon, label, value, sub, tone = "default", accentColor, delta, spark, testId, }) {
56
+ return (_jsxs("div", { "data-testid": testId, className: "flex min-w-0 flex-col gap-1 px-4 py-3", children: [_jsxs("div", { className: "flex items-center justify-between gap-2", 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 items-center gap-0.5 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 })] }));
54
57
  }
55
58
  /** Kompakte Kennzahl-Kachel (ohne Icon) — für dichte KPI-Raster.
56
59
  * StatCard ist die icon-tragende Variante. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer-web",
3
- "version": "0.350.0",
3
+ "version": "0.352.0",
4
4
  "description": "Web-platform bindings for @cosmicdrift/kumiko-renderer. HTML default-primitives, browser history-based navigation, EventSource-backed live events, and a one-call createKumikoApp that mounts the whole stack via react-dom.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -20,11 +20,11 @@
20
20
  }
21
21
  },
22
22
  "dependencies": {
23
- "@cosmicdrift/kumiko-dispatcher-live": "0.350.0",
24
- "@cosmicdrift/kumiko-framework": "0.350.0",
25
- "@cosmicdrift/kumiko-headless": "0.350.0",
26
- "@cosmicdrift/kumiko-renderer": "0.350.0",
27
- "@cosmicdrift/kumiko-types": "0.350.0",
23
+ "@cosmicdrift/kumiko-dispatcher-live": "0.352.0",
24
+ "@cosmicdrift/kumiko-framework": "0.352.0",
25
+ "@cosmicdrift/kumiko-headless": "0.352.0",
26
+ "@cosmicdrift/kumiko-renderer": "0.352.0",
27
+ "@cosmicdrift/kumiko-types": "0.352.0",
28
28
  "@radix-ui/react-dialog": "^1.1.15",
29
29
  "@radix-ui/react-dropdown-menu": "^2.1.16",
30
30
  "@radix-ui/react-label": "^2.1.8",
@@ -72,7 +72,7 @@
72
72
  "@types/react-dom": "^19.2.3",
73
73
  "jsdom": "^29.1.1",
74
74
  "tailwindcss": "^4.3.0",
75
- "@cosmicdrift/kumiko-locale-de": "0.350.0"
75
+ "@cosmicdrift/kumiko-locale-de": "0.352.0"
76
76
  },
77
77
  "repository": {
78
78
  "type": "git",
package/src/changes.json CHANGED
@@ -1,4 +1,32 @@
1
1
  [
2
+ {
3
+ "version": "0.352.0",
4
+ "type": "improvement",
5
+ "title": "Stat group span and column count",
6
+ "detail": "Set span: \"half\" on a stat-group to place two groups side by side."
7
+ },
8
+ {
9
+ "version": "0.352.0",
10
+ "type": "fix",
11
+ "title": "The sidebar drops navs whose parent is missing from the role-projected schema instead of promoting them to the top level",
12
+ "detail": "Navs whose parent does not exist in the app schema are dropped, transitively, instead of being promoted to the top level. A role-projected schema no longer shows an adopted child nav (e.g. AI providers for a TenantAdmin) when its parent section is system-admin only. Children whose parent exists but is filtered out by a workspace allowlist still surface at the top level."
13
+ },
14
+ {
15
+ "version": "0.352.0",
16
+ "type": "improvement",
17
+ "title": "Disabled select options are muted and not choosable in dropdown, radio list, radio cards and segmented control"
18
+ },
19
+ {
20
+ "version": "0.351.0",
21
+ "type": "improvement",
22
+ "title": "Dashboard panels take visibleWhen; stat-group subtitle and strip icons, progress-list sub line, stacked-area lines, markerKinds, seriesColors, legendTotals and ranges",
23
+ "detail": "Dashboard panel gates, stat-group subtitle, progress sub line, stacked-area lines, marker kinds and ranges\n`stat`, `stat-group`, `chart`, `list`, `feed` and `progress-list` panels take `visibleWhen` (`DashboardPanelGate`), like `screen` panels. The gate query gets the screen filter and time range. The panel renders nothing while the gate loads or is unmet, and shows an error with retry when the gate query fails. Panels with the same gate query and payload share one live request. The boot validator checks the gate query and field and rejects `visibleWhen` on stat-group children.\nA labeled `stat-group` takes a `subtitle`. In an unlabeled group (KPI strip) each child keeps its `icon` and `accentColor`. `progress-list` rows take an optional `sub` (`DashboardText`) shown under the bar.\n`stacked-area` results can carry `lines` (`{ key, label, points, dashed? }`, drawn unstacked over the bands) and `markers[].kind`. New chart options: `seriesColors` (key to CSS color, wins over `seriesTones`), and only for stacked-area `markerKinds` (kind to `{ tone }` or `{ color }`: colored pin plus dashed guide line), `legendTotals: false` and `ranges` (a range switch in the panel header that windows bands, lines and markers by `months`). `StackedAreaChart` gets `lines`, `colors` and `showLegendTotals`; `ChartMarker` gets `color`."
24
+ },
25
+ {
26
+ "version": "0.351.0",
27
+ "type": "fix",
28
+ "title": "Tabbed projectionDetail panels taller than the viewport scroll again"
29
+ },
2
30
  {
3
31
  "version": "0.350.0",
4
32
  "type": "improvement",