@cosmicdrift/kumiko-renderer-web 0.350.0 → 0.351.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)
@@ -229,13 +230,41 @@ function StatGroupPanelBody({ panel, label, screenId, screenParams, translate, }
229
230
  if (label === undefined) {
230
231
  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
232
  }
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))) }) }));
233
+ return (_jsx(SectionCard, { title: label, ...(panel.subtitle !== undefined && { subtitle: translate(panel.subtitle) }), 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))) }) }));
233
234
  }
234
- function resolveMarkers(markers, translate, formats) {
235
- return markers?.map((marker) => ({
236
- atMs: marker.atMs,
237
- label: resolveDashboardText(marker.label, translate, formats) ?? "",
238
- }));
235
+ function markerKindColor(kind) {
236
+ if (kind === undefined)
237
+ return undefined;
238
+ if (kind.color !== undefined)
239
+ return kind.color;
240
+ return kind.tone !== undefined ? chartToneColor(kind.tone) : undefined;
241
+ }
242
+ function resolveMarkers(markers, translate, formats, markerKinds) {
243
+ return markers?.map((marker) => {
244
+ const color = typeof marker.kind === "string" ? markerKindColor(markerKinds?.[marker.kind]) : undefined;
245
+ return {
246
+ atMs: marker.atMs,
247
+ label: resolveDashboardText(marker.label, translate, formats) ?? "",
248
+ ...(color !== undefined && { color }),
249
+ };
250
+ });
251
+ }
252
+ function addUtcMonths(atMs, months) {
253
+ const date = new Date(atMs);
254
+ date.setUTCMonth(date.getUTCMonth() + months);
255
+ return date.getTime();
256
+ }
257
+ // Same window as a "1/3/5 years" switch over a plan: `months` ahead of today,
258
+ // pulled back when the data ends earlier, never before the first bucket.
259
+ function rangeWindow(months, anchorMs, firstMs, lastMs) {
260
+ const endMs = Math.min(lastMs, addUtcMonths(anchorMs, months));
261
+ return { startMs: Math.max(firstMs, addUtcMonths(endMs, -months)), endMs };
262
+ }
263
+ function pointsWithin(points, window) {
264
+ return points.filter((p) => p.atMs >= window.startMs && p.atMs <= window.endMs);
265
+ }
266
+ function selectedRangeMonths(ranges, selected) {
267
+ return ranges?.options.find((option) => option.value === selected)?.months;
239
268
  }
240
269
  // A plain bar chart ("changes per day") ships `points` without `series`.
241
270
  const SINGLE_SERIES_KEY = "value";
@@ -272,18 +301,25 @@ function ChartPanelBody({ panel, label, screenParams, translate, }) {
272
301
  const t = useTranslation();
273
302
  const todayLabel = t("kumiko.dashboard.today");
274
303
  const tones = panel.seriesTones;
304
+ const colors = panel.seriesColors;
275
305
  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) => {
306
+ const ranges = panel.chart === "stacked-area" ? panel.ranges : undefined;
307
+ const [selectedRange, setSelectedRange] = useState(ranges?.default);
308
+ const rangeSwitch = ranges !== undefined && selectedRange !== undefined ? (_jsx(ModeSwitch, { value: selectedRange, options: ranges.options.map((option) => ({
309
+ value: option.value,
310
+ label: translate(option.label),
311
+ })), onChange: setSelectedRange, ariaLabel: label, variant: "pill", testId: `dashboard-chart-range-${panel.id}` })) : undefined;
312
+ 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
313
  const series = chartSeries(data, label, translate);
278
314
  const { startMs, endMs } = windowOf(data, series);
279
- const markers = resolveMarkers(data.markers, translate, formats);
315
+ const markers = resolveMarkers(data.markers, translate, formats, panel.markerKinds);
280
316
  const formatValue = formats.formatPanelValue(panel.valueFormat);
281
317
  if (panel.chart === "segment-bars") {
282
318
  return (_jsx(SegmentBarChart, { rows: (data.rows ?? []).map((row) => ({
283
319
  ...row,
284
320
  label: translate(row.label),
285
321
  segments: row.segments.map((seg) => ({ ...seg, label: translate(seg.label) })),
286
- })), tones: tones, ariaLabel: label, formatValue: formatValue, testId: testId }));
322
+ })), tones: tones, colors: colors, ariaLabel: label, formatValue: formatValue, testId: testId }));
287
323
  }
288
324
  if (panel.chart === "stacked-bars") {
289
325
  const times = series.flatMap((s) => s.points.map((p) => p.atMs));
@@ -293,11 +329,25 @@ function ChartPanelBody({ panel, label, screenParams, translate, }) {
293
329
  const prev = all[i - 1];
294
330
  return prev === undefined ? [] : [atMs - prev];
295
331
  }), 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 }));
332
+ 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
333
  }
298
334
  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 }));
335
+ const lines = (data.lines ?? []).map((line) => ({
336
+ ...line,
337
+ label: translate(line.label),
338
+ }));
339
+ const todayMs = data.todayMs ?? undefined;
340
+ const months = selectedRangeMonths(ranges, selectedRange);
341
+ const bucketTimes = series.flatMap((s) => s.points.map((p) => p.atMs));
342
+ const window = months === undefined || bucketTimes.length === 0
343
+ ? { startMs, endMs }
344
+ : rangeWindow(months, todayMs ?? Math.max(...bucketTimes), Math.min(...bucketTimes), Math.max(...bucketTimes));
345
+ const isWindowed = months !== undefined;
346
+ return (_jsx(StackedAreaChart, { series: isWindowed
347
+ ? series.map((s) => ({ ...s, points: pointsWithin(s.points, window) }))
348
+ : series, lines: isWindowed
349
+ ? lines.map((line) => ({ ...line, points: pointsWithin(line.points, window) }))
350
+ : 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
351
  }
302
352
  return (_jsx(TimeseriesChart, { points: data.points ?? [], windowStartMs: startMs, windowEndMs: endMs, markers: markers, formatMarkerTime: formats.formatDay, ...(panel.valueFormat !== undefined && {
303
353
  yAxis: { ticks: 3, format: formatValue },
@@ -336,12 +386,16 @@ function FeedPanelBody({ panel, label, screenParams, translate, }) {
336
386
  function ProgressListPanelBody({ panel, label, screenParams, translate, }) {
337
387
  const formats = useDashboardFormats();
338
388
  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
- }));
389
+ const rows = (data.rows ?? []).map((row, i) => {
390
+ const sub = resolveDashboardText(row.sub, translate, formats);
391
+ return {
392
+ id: String(i),
393
+ label: resolveDashboardText(row.label, translate, formats) ?? "—",
394
+ value: resolveDashboardText(row.value, translate, formats) ?? "—",
395
+ fraction: row.fraction,
396
+ ...(sub !== undefined && { sub }),
397
+ };
398
+ });
345
399
  return _jsx(ProgressList, { rows: rows });
346
400
  } }));
347
401
  }
@@ -439,33 +493,67 @@ function PanelBody({ panel, label, screenId, screenParams, translate, }) {
439
493
  return (_jsx(ProgressListPanelBody, { panel: panel, label: text, screenParams: screenParams, translate: translate }));
440
494
  }
441
495
  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, }) {
496
+ const NO_GATE_PAYLOAD = {};
497
+ function visibilityGate(query, payload) {
498
+ return { key: `${query}|${JSON.stringify(payload)}`, query, payload };
499
+ }
500
+ // Embedded screens carry no screen filter; in-grid panels pass the filter
501
+ // (unless ignoreScreenFilter, never for a stat-group) and the time range.
502
+ function panelVisibilityGate(panel, screenParams) {
503
+ if (panel.kind === "custom" || panel.visibleWhen === undefined)
504
+ return undefined;
505
+ if (panel.kind === "screen")
506
+ return visibilityGate(panel.visibleWhen.query, NO_GATE_PAYLOAD);
507
+ const dropsFilter = panel.kind !== "stat-group" && panel.ignoreScreenFilter === true;
508
+ return visibilityGate(panel.visibleWhen.query, {
509
+ ...(dropsFilter ? {} : screenParams.filterParams),
510
+ ...screenParams.rangeParams,
511
+ });
512
+ }
513
+ // One live query per distinct gate (query + payload), shared by every panel
514
+ // that gates on it — N panels on the same status query would otherwise each
515
+ // open their own request, SSE subscription and refetch per event.
516
+ function VisibilityQueryScope({ gate, children, }) {
446
517
  const parent = useContext(VisibilityQueryContext);
447
- const result = useQuery(query, {}, { live: true });
448
- const value = useMemo(() => new Map(parent).set(query, result), [parent, query, result]);
518
+ const result = useQuery(gate.query, gate.payload, {
519
+ live: true,
520
+ });
521
+ const value = useMemo(() => new Map(parent).set(gate.key, result), [parent, gate.key, result]);
449
522
  return (_jsx(VisibilityQueryContext.Provider, { value: value, children: children }));
450
523
  }
451
- function VisibilityQueryScopes({ queries, children, }) {
452
- const [first, ...rest] = queries;
524
+ function VisibilityQueryScopes({ gates, children, }) {
525
+ const [first, ...rest] = gates;
453
526
  if (first === undefined)
454
527
  return children;
455
- return (_jsx(VisibilityQueryScope, { query: first, children: _jsx(VisibilityQueryScopes, { queries: rest, children: children }) }));
528
+ return (_jsx(VisibilityQueryScope, { gate: first, children: _jsx(VisibilityQueryScopes, { gates: rest, children: children }) }));
456
529
  }
457
- function distinctVisibilityQueries(panels) {
458
- const queries = new Set();
530
+ function distinctVisibilityGates(panels, screenParams) {
531
+ const gates = new Map();
459
532
  for (const panel of panels) {
460
- if (panel.kind === "screen" && panel.visibleWhen !== undefined) {
461
- queries.add(panel.visibleWhen.query);
533
+ const gate = panelVisibilityGate(panel, screenParams);
534
+ if (gate !== undefined)
535
+ gates.set(gate.key, gate);
536
+ }
537
+ return [...gates.values()];
538
+ }
539
+ function GatedGridCell({ panel, screenParams, label, className, children, }) {
540
+ const gate = panelVisibilityGate(panel, screenParams);
541
+ const visibility = useContext(VisibilityQueryContext).get(gate?.key ?? "");
542
+ const visibleWhen = panel.kind === "custom" ? undefined : panel.visibleWhen;
543
+ if (visibleWhen !== undefined) {
544
+ const verdict = evalVisibleWhen(visibleWhen, visibility);
545
+ if (verdict === "error" && visibility?.error) {
546
+ return (_jsx("div", { className: className, "data-testid": `dashboard-panel-${panel.id}`, children: _jsx(PanelError, { label: label, error: visibility.error, onRetry: () => void visibility.refetch() }) }));
462
547
  }
548
+ // Loading renders nothing too: a skeleton that then vanishes flickers.
549
+ if (verdict !== "visible")
550
+ return null;
463
551
  }
464
- return [...queries];
552
+ return _jsx("div", { className: className, children: children });
465
553
  }
466
554
  function ScreenPanelTile({ panel, featureName, translate, }) {
467
555
  const visibleWhen = panel.visibleWhen;
468
- const visibility = useContext(VisibilityQueryContext).get(visibleWhen?.query ?? "");
556
+ const visibility = useContext(VisibilityQueryContext).get(visibleWhen !== undefined ? visibilityGate(visibleWhen.query, NO_GATE_PAYLOAD).key : "");
469
557
  const target = useEmbeddedScreen(featureName, panel.screen);
470
558
  if (target === undefined)
471
559
  return null;
@@ -502,7 +590,7 @@ export function WebDashboardBody({ featureName, screen, translate, }) {
502
590
  const { params: rangeParams, control: rangeControl } = useTimeRange(screen, effectiveTranslate);
503
591
  const screenParams = { filterParams, rangeParams };
504
592
  const loadedAtMs = useLoadedAt(screenParams);
505
- const visibilityQueries = distinctVisibilityQueries(screen.panels);
593
+ const visibilityGates = distinctVisibilityGates(screen.panels, screenParams);
506
594
  const scope = screen.scope;
507
595
  // A raw description is agent-facing prose; only an i18n key is user-facing copy.
508
596
  const translatedDescription = screen.description !== undefined ? effectiveTranslate(screen.description) : undefined;
@@ -510,13 +598,13 @@ export function WebDashboardBody({ featureName, screen, translate, }) {
510
598
  const scopeBadge = scope !== undefined ? (_jsx(StatusBadge, { tone: "accent", testId: `dashboard-${screen.id}-scope`, children: effectiveTranslate(scope.badge) })) : null;
511
599
  // The shell header owns the title; the badge sits right after it there.
512
600
  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) => {
601
+ 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
602
  if (panel.kind === "screen") {
515
603
  return (_jsx(ScreenPanelTile, { panel: panel, featureName: featureName, translate: effectiveTranslate }, panel.id));
516
604
  }
517
605
  const label = panel.kind === "custom" || panel.label === undefined
518
606
  ? undefined
519
607
  : 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));
608
+ 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
609
  }) }) })] }));
522
610
  }
@@ -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.
@@ -1828,7 +1828,10 @@ export function DefaultCard({ slots, options, className, testId, dataAttributes,
1828
1828
  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
1829
  const header = s.header ?? defaultHeader;
1830
1830
  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",
1831
+ const card = (_jsxs("div", { ...dataAttributes, "data-slot": "card", "data-testid": testId, className: cn(framed ? cardSurface({ radius }) : "flex flex-col",
1832
+ // An unframed card has no radius to clip; overflow-hidden would also
1833
+ // zero its flex-item min-height and cut off content inside a scroller.
1834
+ (framed || fillHeight) && "overflow-hidden",
1832
1835
  // Same "no flex-1" reasoning as DefaultForm's own fillHeight card
1833
1836
  // (fw#2722/#2778): sizes to content and only shrinks (min-h-0) once
1834
1837
  // 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.351.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.351.0",
24
+ "@cosmicdrift/kumiko-framework": "0.351.0",
25
+ "@cosmicdrift/kumiko-headless": "0.351.0",
26
+ "@cosmicdrift/kumiko-renderer": "0.351.0",
27
+ "@cosmicdrift/kumiko-types": "0.351.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.351.0"
76
76
  },
77
77
  "repository": {
78
78
  "type": "git",
package/src/changes.json CHANGED
@@ -1,4 +1,15 @@
1
1
  [
2
+ {
3
+ "version": "0.351.0",
4
+ "type": "improvement",
5
+ "title": "Dashboard panels take visibleWhen; stat-group subtitle and strip icons, progress-list sub line, stacked-area lines, markerKinds, seriesColors, legendTotals and ranges",
6
+ "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`."
7
+ },
8
+ {
9
+ "version": "0.351.0",
10
+ "type": "fix",
11
+ "title": "Tabbed projectionDetail panels taller than the viewport scroll again"
12
+ },
2
13
  {
3
14
  "version": "0.350.0",
4
15
  "type": "improvement",