fix: add KPI tile widget type to the dashboard (#26)
CI / build-and-test (pull_request) Successful in 2m24s

Adds a non-chart dashboard widget: a large value with its label, an
optional signed delta versus the previous period, and an optional
sparkline. Four tiles now sit above the charts, 3x1 each.

- KpiTile draws no surface of its own and fills the grid item's, styled
  from the --dialect-* token layer; the delta's up/down colors are new
  tokens rather than literals, so they follow the light/dark toggle.
- Value and delta share one row: stacking them costs a line the tile does
  not have at its default height of one grid cell.
- SparklineChart is a chrome-free line chart built on DialectTheme, so it
  shares the palette and font with the real charts.
- ApexCharts' sparkline.enabled drops the axes on the initial render but
  updateOptions (how the theme toggle recolors a chart) brings the y-axis
  labels back; they are hidden in CSS, which reaches the chart SVG since
  it renders into light DOM.

The registry registration the issue asks for is left out: #21 is blocked,
so there is no widget picker to register with yet. The tiles are wired
into DashboardView the same way the chart widgets are, and their values
come from the translation bundle until the data service (#20) lands.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0124BiJikbhbiEsNfJxdWM69
This commit is contained in:
Pit Friedrich
2026-07-28 19:57:55 +02:00
parent 1b846751dd
commit 21834ec9a0
10 changed files with 399 additions and 10 deletions
@@ -11,6 +11,10 @@
--dialect-ink: light-dark(#14171f, #e6eaf0);
--dialect-border: light-dark(#e6eaf0, #2b303b);
--dialect-radius: 14px;
/* Delta direction (KpiTile). Lightened in dark mode so they keep contrast
against the dark surface instead of going muddy. */
--dialect-positive: light-dark(#1f9d63, #45c98a);
--dialect-negative: light-dark(#d1453b, #f0736a);
/* light-dark() only takes 2 args, so each shadow layer gets its own light-dark()
call and the layers are joined below (var() is a text substitution, so the
comma-separated shadow list still parses correctly). */
@@ -123,6 +127,108 @@ vaadin-app-layout::part(content) {
margin: 0 0 12px 0;
}
/* KpiTile (components/KpiTile.java): a one-row widget, so everything is sized
to survive a 120px grid cell — the sparkline is the only flexible part and
collapses first. */
.dialect-kpi {
display: flex;
flex-direction: column;
/* border-box, or the padding is added on top of the 100% and the tile ends
up taller than the grid item it is supposed to fill. */
box-sizing: border-box;
height: 100%;
min-height: 0;
gap: 2px;
padding: 12px 16px;
overflow: hidden;
}
/* Value and delta on one line, baseline-aligned, so a one-cell-high tile still
has room for the sparkline underneath. */
.dialect-kpi__figure {
display: flex;
align-items: baseline;
gap: 8px;
min-width: 0;
}
.dialect-kpi__label {
font-size: 0.8rem;
font-weight: 500;
letter-spacing: 0.02em;
text-transform: uppercase;
color: var(--dialect-ink);
opacity: 0.6;
/* Long labels shorten rather than wrapping into a second line, which at
h=1 would push the value out of the tile. */
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.dialect-kpi__value {
font-size: clamp(1.4rem, 4cqw, 2rem);
font-weight: 700;
line-height: 1.15;
letter-spacing: -0.02em;
color: var(--dialect-ink);
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Never shrinks: a truncated delta would be unreadable, so the value gives way
first (it ellipsises). */
.dialect-kpi__delta {
display: flex;
align-items: center;
flex: none;
gap: 6px;
font-size: 0.85rem;
font-weight: 600;
}
.dialect-kpi__delta--up {
color: var(--dialect-positive);
}
.dialect-kpi__delta--down {
color: var(--dialect-negative);
}
.dialect-kpi__delta--flat {
color: var(--dialect-ink);
opacity: 0.6;
}
/* ApexCharts' `sparkline.enabled` drops the axes on the initial render, but a
later updateOptions — which is how the theme toggle recolors a chart — brings
the y-axis labels back, clipped to a smudge at the tile's left edge. Hide
them for good; the chart renders into light DOM, so this rule reaches its SVG
(see components/apex-chart.ts). */
apex-chart.dialect-sparkline .apexcharts-yaxis,
apex-chart.dialect-sparkline .apexcharts-xaxis {
display: none;
}
/* The only part allowed to shrink: at h=1 there is no room left for it and it
collapses to nothing, at h≥2 it takes the slack. */
.dialect-kpi__sparkline {
flex: 1 1 0;
min-height: 0;
margin-top: 4px;
overflow: hidden;
}
/* The tile draws no surface of its own — inside a grid item it fills
grid-stack-item-content, which already provides background, radius and
shadow. This also gives the value's `cqw` font size a container to resolve
against, so a narrow tile scales its number down instead of clipping it. */
.grid-stack-item-content > .dialect-kpi {
container-type: inline-size;
}
/* Secondary text (e.g. the layout status line in GridStackView). */
.dialect-muted {
color: var(--dialect-ink);
@@ -32,6 +32,15 @@ gridstack.widgetText=Frei platzierbare Karte.
gridstack.dragHandle=Verschieben
gridstack.close=Schließen
kpi.revenueTotal=Gesamtumsatz
kpi.revenueTotal.value=1,24 Mio. €
kpi.openOrders=Offene Vorgänge
kpi.openOrders.value=38
kpi.newCustomers=Neue Kunden
kpi.newCustomers.value=112
kpi.averageOrderValue=Ø Bestellwert
kpi.averageOrderValue.value=486 €
chart.revenueSeries=Umsatz 2026
chart.pointClick=Serie {0}, Punkt {1}
chart.sliceClick=Slice {0}
@@ -32,6 +32,15 @@ gridstack.widgetText=Freely placeable card.
gridstack.dragHandle=Move
gridstack.close=Close
kpi.revenueTotal=Total revenue
kpi.revenueTotal.value=€1.24M
kpi.openOrders=Open orders
kpi.openOrders.value=38
kpi.newCustomers=New customers
kpi.newCustomers.value=112
kpi.averageOrderValue=Avg. order value
kpi.averageOrderValue.value=€486
chart.revenueSeries=Revenue 2026
chart.pointClick=Series {0}, point {1}
chart.sliceClick=Slice {0}
@@ -32,6 +32,15 @@ gridstack.widgetText=Tarjeta de colocación libre.
gridstack.dragHandle=Mover
gridstack.close=Cerrar
kpi.revenueTotal=Ingresos totales
kpi.revenueTotal.value=1,24 M€
kpi.openOrders=Pedidos abiertos
kpi.openOrders.value=38
kpi.newCustomers=Nuevos clientes
kpi.newCustomers.value=112
kpi.averageOrderValue=Valor medio del pedido
kpi.averageOrderValue.value=486 €
chart.revenueSeries=Ingresos 2026
chart.pointClick=Serie {0}, punto {1}
chart.sliceClick=Sector {0}