UI widgets
Kumiko apps don’t build their own cards, badges or charts. The renderer ships a
mid-level widget kit that sits between the raw primitives (usePrimitives())
and full declarative screens — everything token-based, theme-aware and covered
by the styleguide e2e suite. App repos enforce this with the kumiko-guard-ui
CI guards: hand-rolled *Card/*Table/*Badge components, raw palette
classes, inline styles and hardcoded UI strings fail the build.
Import everything from @cosmicdrift/kumiko-renderer-web:
import { StatCard, MiniStat, Sparkline, // KPIs SectionCard, CollapsibleSection, // sections StatusBadge, // status pills (tone: ok|warn|bad|critical|muted) TimeseriesChart, StatusBarChart, // SVG charts, no chart dependency QueryTable, // query-backed table with loading/error/empty DetailList, ProgressBar, ModeSwitch, // key-value list, bar, segmented control EmptyState, LoadingState, ErrorState, // states useDraft, // form state: draft + patch + field(name) NumberField, MoneyField, PercentField, // number fields (unit lives in the label) SelectField, DateField, TextField, // select / date / text BooleanField, TextareaField, RangeField, FileField, AiTextField, AiTextArea, // AI-augmented text (ghost-text, correct/translate/rewrite) ResultPanel, ResultTable, ComparisonTable, // live result panel + tables} from "@cosmicdrift/kumiko-renderer-web";For data, use the hook set from @cosmicdrift/kumiko-renderer instead of
useState+useEffect+fetch: useQuery (pass live: true for SSE
invalidation), useMutation and useDisclosure.
Pick by concern
Section titled “Pick by concern”| You need | Reach for |
|---|---|
| KPI tiles with icon, delta, trend | StatCard, MiniStat, Sparkline |
| A titled section with an action slot | SectionCard |
| Status pills (ok / warn / bad / critical) | StatusBadge + the --color-status-* theme tokens |
| A time series or an uptime bar | TimeseriesChart (gaps render as dips via value: null), StatusBarChart |
| A table driven by a query | QueryTable — or go fully declarative with the projectionList screen type |
| A whole KPI page | the dashboard screen type below — no JSX at all |
| Empty / loading / error surfaces | EmptyState, LoadingState, ErrorState |
| A calculator form (number fields → live result) | useDraft + the field widgets + ResultPanel — see below |
| A single labelled input | NumberField / MoneyField / PercentField / SelectField / DateField / TextField / BooleanField / TextareaField / RangeField / FileField |
| A text field with AI ghost-text/correct/translate/rewrite | AiTextField / AiTextArea — drop-in for TextField/TextareaField; degrades gracefully when the server’s ai-text feature (enterprise) isn’t mounted |
| A computed result list or table | ResultPanel (rows + footer action slot), ResultTable, ComparisonTable (transposed metric × variant) |
Two rules keep apps consistent: colors only through theme tokens
(bg-primary, text-status-ok, brand overrides in src/styles.css via
@theme), and user-visible text only through t("…") keys.
The catalog, live
Section titled “The catalog, live”The styleguide sample renders every widget on one page — it is both the visual reference and the e2e surface for the kit:
cd samples/apps/styleguide && bun dev # → http://localhost:4180/widgetsThe catalog page composes the widgets directly in a custom screen:
// Visueller Katalog des Widget-Kits — jede Sektion zeigt ein Widget mit// statischen Daten. Dient zugleich als e2e-Renderfläche (content.spec).
import { usePrimitives } from "@cosmicdrift/kumiko-renderer";import { BooleanField, CollapsibleSection, ComparisonTable, DateField, DetailList, EmptyState, MiniStat, ModeSwitch, MoneyField, PercentField, ProgressBar, RangeField, ResultPanel, ResultTable, SectionCard, SelectField, StatCard, StatusBadge, StatusBarChart, TextareaField, TextField, TimeseriesChart, useDraft,} from "@cosmicdrift/kumiko-renderer-web";import { Wallet } from "lucide-react";import { type ReactNode, useState } from "react";
const UPTIME = Array.from({ length: 90 }, (_, i) => ({ key: `day-${i}`, level: i === 30 ? 0.25 : i % 17 === 0 ? 0.75 : 1, tone: i === 30 ? ("critical" as const) : i % 17 === 0 ? ("warn" as const) : ("ok" as const), label: `Tag ${i + 1}`,}));
const RESPONSE_TIMES = Array.from({ length: 48 }, (_, i) => ({ atMs: i * 30 * 60 * 1000, value: i === 20 ? null : 120 + Math.round(80 * Math.abs(Math.sin(i / 5))),}));
export function Widgets(): ReactNode { const [mode, setMode] = useState<"annuity" | "fixed">("annuity"); return ( <div className="flex flex-col gap-6 p-6" data-testid="widgets-page"> <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4"> <StatCard icon={<Wallet className="size-4" aria-hidden="true" />} label="Portfolio" value="92.753 €" sub="über 4 Konten" delta={{ value: "2,1 %", direction: "up", tone: "positive" }} spark={[3, 5, 4, 7, 6, 9, 11, 10]} /> <StatCard label="Restschuld" value="184.000 €" tone="warn" trend="−1.200 €/Monat" /> <MiniStat label="Zins p.a." value="3,1 %" /> <MiniStat label="Rate" value="890 €" tone="positive" emphasize /> </div>
<SectionCard title="Uptime" subtitle="Letzte 90 Tage" action={<StatusBadge tone="ok">Operational</StatusBadge>} > <StatusBarChart ariaLabel="Uptime der letzten 90 Tage" entries={UPTIME} startLabel="90 Tage" endLabel="heute" /> </SectionCard>
<SectionCard title="Antwortzeit" subtitle="Letzte 24 Stunden"> <TimeseriesChart points={RESPONSE_TIMES} windowStartMs={0} windowEndMs={24 * 60 * 60 * 1000} ariaLabel="Antwortzeit-Verlauf" axisLabels={{ start: "vor 24h", mid: "vor 12h", end: "jetzt" }} /> </SectionCard>
<SectionCard title="Status-Tones" action={<ProgressBar value={0.65} className="w-40" />}> <div className="flex flex-wrap gap-2"> <StatusBadge tone="ok">operational</StatusBadge> <StatusBadge tone="warn">degraded</StatusBadge> <StatusBadge tone="bad">partial outage</StatusBadge> <StatusBadge tone="critical">major outage</StatusBadge> <StatusBadge tone="muted">maintenance</StatusBadge> </div> </SectionCard>
<SectionCard title="Tilgungsmodell" action={ <ModeSwitch value={mode} onChange={setMode} options={[ { value: "annuity", label: "Annuität" }, { value: "fixed", label: "Feste Rate" }, ]} /> } > <DetailList rows={[ { label: "Modell", value: mode === "annuity" ? "Annuität" : "Feste Rate" }, { label: "Sollzins", value: "3,1 %" }, { label: "Status", value: <StatusBadge tone="ok">aktiv</StatusBadge> }, ]} /> </SectionCard>
<CollapsibleSection title="Erweiterte Einstellungen"> <EmptyState title="Noch keine Sondertilgungen" description="Lege die erste an, um den Plan zu verkürzen." /> </CollapsibleSection>
<FinancingCalculatorDemo /> <FormFieldsDemo /> <ComparisonDemo /> </div> );}
// Feld-Widgets für Nicht-Zahl-Typen (Select/Date/Text/Boolean/Textarea) —// wrappen dieselben usePrimitives-Input-kinds wie NumberField.interface FieldsDraft { readonly land: string; readonly datum: string; readonly name: string; readonly aktiv: boolean; readonly notiz: string; readonly abruf: number;}
const FIELDS_DEFAULTS: FieldsDraft = { land: "NW", datum: "2026-07-10", name: "", aktiv: true, notiz: "", abruf: 40,};
function FormFieldsDemo(): ReactNode { const { draft, patch, field } = useDraft<FieldsDraft>(FIELDS_DEFAULTS); const { Button } = usePrimitives(); return ( <SectionCard title="Feld-Widgets"> <TextField label="Name" {...field("name")} placeholder="z. B. Variante A" /> <SelectField label="Bundesland" {...field("land")} options={[ { value: "NW", label: "Nordrhein-Westfalen" }, { value: "BY", label: "Bayern" }, ]} /> <DateField label="Datum" {...field("datum")} onChange={(v) => patch({ datum: v ?? "" })} /> <BooleanField label="Makler einbeziehen" {...field("aktiv")} /> <RangeField label={`Abruf: ${draft.abruf} %`} {...field("abruf")} min={0} max={100} step={5} /> <TextareaField label="Notiz" {...field("notiz")} rows={3} /> <div className="flex items-center gap-2"> <Button size="sm" variant="secondary" onClick={() => {}}> Klein </Button> <Button onClick={() => {}}>Standard</Button> </div> </SectionCard> );}
// Transponierter Vergleich (Zeile = Kennzahl, Spalte = Variante), beste// hervorgehoben — für Szenario-/Angebotsvergleiche.function ComparisonDemo(): ReactNode { const euro = (n: number): string => `${n.toLocaleString("de-DE")} €`; const scenarios = [ { name: "A", rate: 890, interest: 84000 }, { name: "B", rate: 940, interest: 71000 }, ]; const minIndex = (pick: (s: (typeof scenarios)[number]) => number): number => { let bestI = 0; let bestV = Number.POSITIVE_INFINITY; scenarios.forEach((s, i) => { const v = pick(s); if (v < bestV) { bestV = v; bestI = i; } }); return bestI; }; return ( <SectionCard title="Vergleich"> <ComparisonTable columns={scenarios} columnHeader={(s) => s.name} columnKey={(s) => s.name} metricLabel="Kennzahl" metrics={[ { label: "Monatsrate", value: (s) => euro(s.rate), bestIndex: () => minIndex((s) => s.rate), }, { label: "Gesamtzins", value: (s) => euro(s.interest), bestIndex: () => minIndex((s) => s.interest), }, ]} /> </SectionCard> );}
// Live-Input-Rechner: useDraft → pure Berechnung → ResultPanel/ResultTable.// Belegt, dass das Form-Kit das Rechner-Muster der Apps ohne Custom-CSS trägt.interface CalcDraft { readonly sum: number | undefined; readonly interest: number | undefined; readonly repayment: number | undefined;}
const CALC_DEFAULTS: CalcDraft = { sum: 300000, interest: 3.8, repayment: 2 };
function FinancingCalculatorDemo(): ReactNode { const { draft, field } = useDraft<CalcDraft>(CALC_DEFAULTS); const ready = draft.sum !== undefined && draft.interest !== undefined; const rate = ready ? Math.round((draft.sum * ((draft.interest + (draft.repayment ?? 0)) / 100)) / 12) : 0; const euro = (n: number): string => `${n.toLocaleString("de-DE")} €`; return ( <div className="grid gap-4 lg:grid-cols-2"> <SectionCard title="Finanzierung"> <MoneyField label="Darlehen" {...field("sum")} required /> <PercentField label="Sollzins" {...field("interest")} required /> <PercentField label="Tilgung" {...field("repayment")} /> </SectionCard> <ResultPanel title="Ergebnis" empty={!ready} emptyText="Darlehen und Zins eingeben." rows={[ { label: "Darlehen", value: euro(draft.sum ?? 0) }, { label: "Monatsrate", value: euro(rate), emphasize: true }, ]} > <ResultTable columns={[ { header: "Tranche", cell: (r: { label: string; rate: number }) => r.label }, { header: "Rate", align: "right", cell: (r) => euro(r.rate) }, ]} rows={[{ label: "Bankdarlehen", rate }]} rowKey={(r) => r.label} /> </ResultPanel> </div> );}Calculator forms
Section titled “Calculator forms”The other recurring app shape — N number fields → a pure function → a live
result panel — has its own kit so screens stay composition, not hand-wired
<Field><Input> blocks and hand-built <dl>/<table>. useDraft(defaults)
holds the form state and hands each field its wiring via field(name)
({ id, name, value, onChange }); the field widgets wrap the matching
usePrimitives() input kind; ResultPanel renders the empty state, a
DetailList of rows and an optional footer action:
const { draft, field } = useDraft(DEFAULTS);const mix = calcFinancingMix(draft); // pure, testable, lives in lib/// …<MoneyField label={t("kfw.field.sum")} required {...field("sum")} /><PercentField label={t("kfw.field.interest")} {...field("interest")} /><ResultPanel title={t("kfw.result")} empty={mix === null} rows={[ { label: t("kfw.total"), value: euro(mix.totalSum) }, { label: t("kfw.rate"), value: euro(mix.startRate), emphasize: true },]}> <ResultTable columns={…} rows={mix.tranches} rowKey={(t) => t.key} /></ResultPanel>Two rules specific to this kit: units (€ / %) live in the label
(t("…Summe (€)")), never as a separate badge; and the calc stays a pure
function in lib/ with its own test, never inline in the .tsx. For
side-by-side variant comparisons (scenario / offer tables) use
ComparisonTable — row = metric, column = variant, best per row highlighted.
The live examples are in the styleguide catalog above (FinancingCalculatorDemo,
FormFieldsDemo, ComparisonDemo).
Declarative dashboards
Section titled “Declarative dashboards”The same content works without any JSX: the dashboard screen type renders
stat, chart and list panels straight from schema. Labels are i18n keys (the
boot validator checks coverage), queries return pre-formatted records, and the
framework renders through the widgets above:
// Widgets-Feature (server). Zwei Screens:// widgets — custom Katalog-Screen (alle Widgets mit statischen Daten)// widgets-dashboard — deklarativer dashboard-Screen (stat/chart/list-Panels// aus Demo-Queries) — der Schema-getriebene Gegenpart.
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";import { z } from "zod";
// Statische Demo-Zeitreihe (48 Punkte à 30 Minuten) — kein Date-API,// das Fenster ist relativ zu 0 definiert.const RESPONSE_POINTS = Array.from({ length: 48 }, (_, i) => ({ atMs: i * 30 * 60 * 1000, value: i === 20 ? null : 120 + Math.round(80 * Math.abs(Math.sin(i / 5))),}));
export const widgetsFeature = defineFeature("widgets", (r) => { r.screen({ id: "widgets", type: "custom", renderer: { react: { __component: "widgets" } } });
r.screen({ id: "widgets-dashboard", type: "dashboard", filter: { id: "region", label: "widgets:dashboard:filter-region", kind: "select", options: [ { value: "eu", label: "widgets:dashboard:filter-region-eu" }, { value: "us", label: "widgets:dashboard:filter-region-us" }, ], }, panels: [ { kind: "stat", id: "portfolio", label: "widgets:dashboard:portfolio", query: "widgets:query:metrics:portfolio-stat", valueField: "value", subField: "sub", toneField: "tone", deltaField: "delta", deltaDirectionField: "deltaDirection", deltaToneField: "deltaTone", icon: { react: { __component: "widgets-dashboard-kpi-icon" } }, accentColor: "var(--color-primary)", }, { kind: "stat-group", id: "net-worth", label: "widgets:dashboard:net-worth", stats: [ { kind: "stat", id: "net-worth-assets", label: "widgets:dashboard:net-worth-assets", query: "widgets:query:metrics:net-worth-assets", valueField: "value", }, { kind: "stat", id: "net-worth-debts", label: "widgets:dashboard:net-worth-debts", query: "widgets:query:metrics:net-worth-debts", valueField: "value", }, ], }, { kind: "chart", id: "response-times", label: "widgets:dashboard:response-times", chart: "timeseries", query: "widgets:query:metrics:response-times", }, { kind: "list", id: "latest", label: "widgets:dashboard:latest", query: "widgets:query:metrics:latest-items", columns: [ { field: "name", label: "widgets:dashboard:col-name" }, { field: "status", label: "widgets:dashboard:col-status" }, ], }, { kind: "feed", id: "upcoming", label: "widgets:dashboard:upcoming", query: "widgets:query:metrics:upcoming-events", }, { kind: "progress-list", id: "goal-progress", label: "widgets:dashboard:goal-progress", query: "widgets:query:metrics:goal-progress", }, { kind: "custom", id: "filter-echo", component: { react: { __component: "widgets-dashboard-filter-echo" } }, }, ], });
r.queryHandler( "metrics:portfolio-stat", z.object({ region: z.string().optional() }), async ({ payload: { region } }) => ({ value: region === "us" ? "38.120 $" : region === "eu" ? "54.630 €" : "92.753 €", sub: "über 4 Konten", tone: "positive", delta: "12 %", deltaDirection: "up", deltaTone: "positive", }), { access: { openToAll: true } }, ); r.queryHandler( "metrics:net-worth-assets", z.object({ region: z.string().optional() }), async () => ({ value: "120.000 €" }), { access: { openToAll: true } }, ); r.queryHandler( "metrics:net-worth-debts", z.object({ region: z.string().optional() }), async () => ({ value: "65.370 €" }), { access: { openToAll: true } }, ); r.queryHandler( "metrics:response-times", z.object({}), async () => ({ points: RESPONSE_POINTS, windowStartMs: 0, windowEndMs: 24 * 60 * 60 * 1000, }), { access: { openToAll: true } }, ); r.queryHandler( "metrics:latest-items", z.object({}), async () => ({ rows: [ { id: "i1", name: "API-Timeout eu-central", status: "resolved" }, { id: "i2", name: "Zertifikat erneuert", status: "done" }, ], nextCursor: null, }), { access: { openToAll: true } }, ); r.queryHandler( "metrics:upcoming-events", z.object({}), async () => ({ rows: [ { primary: "Zinsanpassung Baudarlehen", trailing: "Aug 2026" }, { primary: "Bausparvertrag zuteilungsreif", trailing: "Okt 2026" }, ], }), { access: { openToAll: true } }, ); r.queryHandler( "metrics:goal-progress", z.object({}), async () => ({ rows: [ { label: "Baudarlehen", value: "42.000 € offen", fraction: 0.71 }, { label: "Autokredit", value: "3.200 € offen", fraction: 0.92 }, ], }), { access: { openToAll: true } }, );
r.translations({ keys: { "screen:widgets.title": { de: "Widgets", en: "Widgets" }, "screen:widgets-dashboard.title": { de: "Dashboard (deklarativ)", en: "Dashboard (declarative)", }, "widgets:dashboard:portfolio": { de: "Portfolio", en: "Portfolio" }, "widgets:dashboard:net-worth": { de: "Netto-Vermögen", en: "Net worth" }, "widgets:dashboard:net-worth-assets": { de: "Vermögen", en: "Assets" }, "widgets:dashboard:net-worth-debts": { de: "Schulden", en: "Debts" }, "widgets:dashboard:response-times": { de: "Antwortzeit", en: "Response time" }, "widgets:dashboard:latest": { de: "Neueste Ereignisse", en: "Latest events" }, "widgets:dashboard:col-name": { de: "Name", en: "Name" }, "widgets:dashboard:col-status": { de: "Status", en: "Status" }, "widgets:dashboard:upcoming": { de: "Nächste Termine", en: "Upcoming" }, "widgets:dashboard:goal-progress": { de: "Tilgungsfortschritt", en: "Payoff progress" }, "widgets:dashboard:filter-region": { de: "Region", en: "Region" }, "widgets:dashboard:filter-region-eu": { de: "Europa", en: "Europe" }, "widgets:dashboard:filter-region-us": { de: "USA", en: "USA" }, }, });
r.nav({ id: "widgets", label: "Widgets", parent: "gallery:nav:styleguide", screen: "widgets:screen:widgets", icon: "layout-grid", order: 20, }); r.nav({ id: "widgets-dashboard", label: "Dashboard (deklarativ)", parent: "gallery:nav:styleguide", screen: "widgets:screen:widgets-dashboard", icon: "gauge", order: 21, });});Open it side by side with the catalog at /widgets-dashboard — same visuals,
zero component code.
Escape hatches
Section titled “Escape hatches”type: "custom" screens stay possible but need an inline allowlist tag with a
reason, e.g. an interactive calculator that runs an engine in the browser:
// kumiko-lint-ignore app-feature-structure interactive calculator (browser engine), no declarative fitr.screen({ id: "credit-calculator", type: "custom", ... });The same tag convention (// kumiko-lint-ignore <guard-slug> <reason>) covers
justified inline styles (runtime values like a chart width from data) and
domain components whose names collide with the primitive naming rules.