Skip to content

UI widgets

The renderer has a widget layer between raw primitives (usePrimitives()) and fully declarative screens. Use it for app-specific screens. The styleguide sample is the visual reference and its E2E suite covers the catalog.

If you are choosing a visual pattern, open the Styleguide catalog first. It shows the same building blocks in custom composition, generated forms, and a declarative dashboard before you write a new screen.

App repositories also run UI guards. They reject common replacements for the widget kit, including hand-built *Card/*Table/*Badge components, raw palette classes, inline styles, and hardcoded UI strings.

  • Your app renders through @cosmicdrift/kumiko-renderer/web (any app scaffolded by kumiko create does).
  • You’ve read Features and composition so r.screen and the feature body are familiar.

Import the widgets your screen needs from @cosmicdrift/kumiko-renderer/web:

import {
StatCard, MiniStat, Sparkline, // KPIs
SectionCard, CollapsibleSection, // sections
StatusBadge, // status pill with dot (tone: ok|warn|bad|critical|muted|accent)
TimeseriesChart, StatusBarChart, // SVG charts, no chart dependency
QueryTable, // query-backed table with loading/error/empty
DetailList, ProgressBar, ModeSwitch, // key-value list, bar, segmented control
Drawer, // slide-in panel (side: left|right|top|bottom)
FloatingPanel, // non-modal window: drag, resize, remembered geometry
InfinityList, // cursor-paginated scroll list, loads on IntersectionObserver
ResizablePanelGroup, ResizablePanel, ResizableHandle, // draggable split (list | reading pane)
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";

The widget kit is part of the package’s public barrel. Do not import from renderer/src/web/widgets directly.

For server data, use useQuery from @cosmicdrift/kumiko-renderer and set live: true when the screen should refetch after SSE invalidation. Use useMutation for writes and useDisclosure for open/close state.

You needReach for
KPI tiles with icon, delta, trendStatCard, MiniStat, Sparkline
A titled section with an action slotSectionCard
Status pills (ok / warn / bad / critical / muted), each with a 6 px dotStatusBadge + the --color-status-* theme tokens
A time series or an uptime barTimeseriesChart (gaps render as dips via value: null), StatusBarChart
A table driven by a queryQueryTable, or go fully declarative with the projectionList screen type
A whole KPI pagethe dashboard screen type below
A side panel next to a list (detail view, mail reader, edit form)Drawer, wraps the Sheet primitive with header/body/footer slots. On a declarative screen, use a kind: "drawer" action instead (see below)
A tool window that stays open while the user works on the page behind it (assistant, notes, preview)FloatingPanel, non-modal, draggable and resizable, see below
A scroll list that loads more on demand (inbox, activity feed)InfinityList, cursor pagination, no pager UI
A draggable split between two panes (mail list + reading pane)ResizablePanelGroup / ResizablePanel / ResizableHandle, vendored shadcn, real drag handle (not the browser’s native resize corner)
Empty / loading / error surfacesEmptyState, LoadingState, ErrorState
A calculator form (number fields → live result)useDraft + the field widgets + ResultPanel, see below
A single labelled inputNumberField / MoneyField / PercentField / SelectField / DateField / TextField / BooleanField / TextareaField / RangeField / FileField
A text field with AI ghost-text/correct/translate/rewriteAiTextField / AiTextArea, drop-in for TextField/TextareaField; expects the app to serve the ai-text:query:run query itself and degrades gracefully when it does not
A computed result list or tableResultPanel (rows + footer action slot), ResultTable, ComparisonTable (transposed metric × variant)
Syntax-highlighted JSON inspector (payloads, logs, debug)usePrimitives().JsonView (DefaultJsonView, handles circular structures and BigInt)

FloatingPanel is a window above the page that does not block it: no overlay, no focus trap, aria-modal="false". The page behind it stays usable, so the panel can stay open while the user navigates.

<FloatingPanel
title={t("notes:panel:title")}
headerActions={
<Button variant="ghost" title={t("notes:panel:close")} onClick={close}>
{t("notes:panel:close")}
</Button>
}
storageKey="notes:panel"
>
<NotesList />
</FloatingPanel>

The header is the move handle. A pointer-down on a button, link or input inside headerActions does not start a drag. A grip button in the header moves the panel with the arrow keys (16 px per press, 40 px with Shift), and each of the four edges is a focusable separator that resizes the same way. The corners resize with the pointer only.

Without a defaultGeometry the panel opens at 400 × 560 px in the bottom right corner, 24 px from the edges. minWidthPx and minHeightPx default to 280 and 200, the maximum is the viewport minus the margin. When the browser window shrinks, the panel is resized and moved back into view. With a storageKey the geometry goes to localStorage after every move or resize and is restored on the next mount. If storage is blocked or holds something unreadable, the panel uses the default geometry.

Below 768 px, the breakpoint useIsNarrowViewport also reports, the panel renders as a full-screen sheet with the same title and header actions.

For icon-only header actions, Button takes title for the native tooltip and pressed for aria-pressed on toggle buttons.

For a calculator, use useDraft(defaults) with a pure calculation function and ResultPanel. This keeps the screen focused on composition instead of custom <Field><Input> and <dl>/<table> markup. 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>

Use ComparisonTable for side-by-side scenarios or offers. The live examples are in the styleguide catalog: FinancingCalculatorDemo, FormFieldsDemo, and ComparisonDemo.

The dashboard screen type renders stat, chart, feed, progress, and list panels from schema. Labels are i18n keys, the boot validator checks their coverage, and the panel queries return the records consumed by the widgets:

// 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";
import { demoMoney, demoMonthYear, demoPercent, demoText } from "./demo-locale";
import { WIDGETS_I18N } from "./i18n";
// 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))),
}));
const LOAN_START_MS = Date.UTC(2020, 0, 1);
const LOAN_TODAY_MS = Date.UTC(2026, 5, 15);
const LOAN_MONTHS = 240;
const LOAN_PRINCIPAL_MINOR = 25_000_000;
const LOAN_MONTHLY_RATE = 0.035 / 12;
const loanMonthMs = (monthIndex: number): number => Date.UTC(2020, monthIndex, 1);
// Annuity loan over 20 years: remaining principal falls, cumulative interest
// grows. "Today" is a fixed date, not the clock, so the window and the
// screenshots stay stable.
const growth = (1 + LOAN_MONTHLY_RATE) ** LOAN_MONTHS;
const annuityMinor = (LOAN_PRINCIPAL_MINOR * LOAN_MONTHLY_RATE * growth) / (growth - 1);
// Month index -> extra repayment, which shortens the term below LOAN_MONTHS.
const EXTRA_REPAYMENTS_MINOR: ReadonlyMap<number, number> = new Map([
[84, 3_000_000],
[150, 2_000_000],
]);
let balanceMinor: number = LOAN_PRINCIPAL_MINOR;
let interestPaidMinor = 0;
let payoffMonth: number | undefined;
const LOAN_REMAINING_POINTS = [{ atMs: loanMonthMs(0), value: LOAN_PRINCIPAL_MINOR }];
const LOAN_INTEREST_POINTS = [{ atMs: loanMonthMs(0), value: 0 }];
for (let i = 1; i <= LOAN_MONTHS; i++) {
const interest = balanceMinor * LOAN_MONTHLY_RATE;
const extraRepayment = EXTRA_REPAYMENTS_MINOR.get(i) ?? 0;
balanceMinor = Math.max(0, balanceMinor - (annuityMinor - interest) - extraRepayment);
if (balanceMinor === 0) payoffMonth ??= i;
interestPaidMinor += interest;
LOAN_REMAINING_POINTS.push({ atMs: loanMonthMs(i), value: Math.round(balanceMinor) });
LOAN_INTEREST_POINTS.push({ atMs: loanMonthMs(i), value: Math.round(interestPaidMinor) });
}
const LOAN_SERIES = [
{ key: "remaining", label: "widgets:dashboard:loan-remaining", points: LOAN_REMAINING_POINTS },
{ key: "interest", label: "widgets:dashboard:loan-interest", points: LOAN_INTEREST_POINTS },
];
const LOAN_MARKERS = [
...[...EXTRA_REPAYMENTS_MINOR.keys()].map((monthIndex, i) => ({
atMs: loanMonthMs(monthIndex),
label: { i18nKey: `widgets:dashboard:marker-extra-${i + 1}` },
kind: "extra",
})),
{
atMs: loanMonthMs(payoffMonth ?? LOAN_MONTHS),
label: { i18nKey: "widgets:dashboard:marker-payoff" },
kind: "payoff",
},
];
const SENDERS = ["William Smith", "Alice Smith", "Bob Johnson", "Emily Davis"] as const;
const SUBJECTS = ["Meeting Tomorrow", "Re: Project Update", "Weekend Plans", "Re: Budget"] as const;
// Static demo inbox (18 messages) for the InfinityList demo — enough
// rows to load several pages beyond pageSize=6.
const INBOX_MESSAGES = Array.from({ length: 18 }, (_, i) => ({
id: `m${i + 1}`,
// Cast is sound: `i % SENDERS.length` is always in [0, SENDERS.length) —
// noUncheckedIndexedAccess can't see that from a computed index, unlike
// the removed `as string` this replaces (which had no such guarantee).
sender: SENDERS[i % SENDERS.length] as (typeof SENDERS)[number],
subject: SUBJECTS[i % SUBJECTS.length] as (typeof SUBJECTS)[number],
snippet: "Hi team, just a reminder about our meeting tomorrow at 10 AM.",
// % 4 statt % 3: bleibt an der sender/subject-Rotation ausgerichtet, sonst
// ist irgendwann jede Kombination mal unread und der Filter zeigt visuell
// keinen Unterschied.
unread: i % 4 === 0,
}));
function openAmount(amount: number, locale: string): string {
return demoText("widgets:dashboard:demo-amount-open", locale).replace(
"{amount}",
demoMoney(amount, "EUR", locale),
);
}
export const widgetsFeature = defineFeature("widgets", (r) => {
r.screen({ id: "widgets", type: "custom", renderer: { react: { __component: "widgets" } } });
r.screen({
id: "widgets-forms",
type: "custom",
renderer: { react: { __component: "widgets-forms" } },
});
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: "chart",
id: "loan",
label: "widgets:dashboard:loan",
chart: "stacked-area",
query: "widgets:query:metrics:loan",
valueFormat: { kind: "currency", currency: "EUR", fractionDigits: 0 },
brush: true,
legendTotals: false,
initialWindow: "from-today",
markerLegend: "legend",
markerLabels: true,
markerKinds: {
extra: { tone: "active", label: "widgets:dashboard:kind-extra" },
payoff: { tone: "positive", label: "widgets:dashboard:kind-payoff" },
},
ranges: {
default: "max",
options: [
{ value: "y1", label: "widgets:dashboard:range-y1", months: 12 },
{ value: "y3", label: "widgets:dashboard:range-y3", months: 36 },
{ value: "y5", label: "widgets:dashboard:range-y5", months: 60 },
{ value: "max", label: "widgets:dashboard:range-all" },
],
},
},
{
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: "text",
id: "dashboard-help",
label: "widgets:dashboard:help-title",
blocks: [
{ kind: "heading", text: "widgets:dashboard:help-heading" },
{ kind: "paragraph", text: "widgets:dashboard:help-body" },
{
kind: "list",
items: ["widgets:dashboard:help-item-1", "widgets:dashboard:help-item-2"],
},
{ kind: "code", code: "kumiko check --explain" },
{ kind: "hint", text: "widgets:dashboard:help-hint" },
],
},
{
kind: "custom",
id: "filter-echo",
component: { react: { __component: "widgets-dashboard-filter-echo" } },
},
],
});
r.queryHandler({
name: "metrics:portfolio-stat",
schema: z.object({ region: z.string().optional() }),
handler: async ({ payload: { region } }, ctx) => ({
value:
region === "us"
? demoMoney(38120, "USD", ctx.locale)
: demoMoney(region === "eu" ? 54630 : 92753, "EUR", ctx.locale),
sub: demoText("widgets:catalog:portfolio-sub", ctx.locale),
tone: "positive",
delta: demoPercent(0.12, ctx.locale),
deltaDirection: "up",
deltaTone: "positive",
}),
access: {
openToAll: {
reason:
"demo dashboard widget: returns static canned data with no per-user or " +
"tenant scoping; any signed-in user may query it",
},
},
});
r.queryHandler({
name: "metrics:net-worth-assets",
schema: z.object({ region: z.string().optional() }),
handler: async (_query, ctx) => ({ value: demoMoney(120000, "EUR", ctx.locale) }),
access: {
openToAll: {
reason:
"demo dashboard widget: returns static canned data with no per-user or " +
"tenant scoping; any signed-in user may query it",
},
},
});
r.queryHandler({
name: "metrics:net-worth-debts",
schema: z.object({ region: z.string().optional() }),
handler: async (_query, ctx) => ({ value: demoMoney(65370, "EUR", ctx.locale) }),
access: {
openToAll: {
reason:
"demo dashboard widget: returns static canned data with no per-user or " +
"tenant scoping; any signed-in user may query it",
},
},
});
r.queryHandler({
name: "metrics:response-times",
schema: z.object({}),
handler: async () => ({
points: RESPONSE_POINTS,
windowStartMs: 0,
windowEndMs: 24 * 60 * 60 * 1000,
}),
access: {
openToAll: {
reason:
"demo dashboard widget: returns static canned data with no per-user or " +
"tenant scoping; any signed-in user may query it",
},
},
});
r.queryHandler({
name: "metrics:loan",
schema: z.object({}),
handler: async () => ({
series: LOAN_SERIES,
markers: LOAN_MARKERS,
windowStartMs: LOAN_START_MS,
windowEndMs: loanMonthMs(LOAN_MONTHS),
todayMs: LOAN_TODAY_MS,
}),
access: {
openToAll: {
reason:
"demo dashboard widget: returns static canned data with no per-user or " +
"tenant scoping; any signed-in user may query it",
},
},
});
r.queryHandler({
name: "metrics:latest-items",
schema: z.object({}),
handler: async (_query, ctx) => ({
rows: [
{
id: "i1",
name: demoText("widgets:dashboard:demo-incident-timeout", ctx.locale),
status: "resolved",
},
{
id: "i2",
name: demoText("widgets:dashboard:demo-incident-certificate", ctx.locale),
status: "done",
},
],
nextCursor: null,
}),
access: {
openToAll: {
reason:
"demo dashboard widget: returns static canned data with no per-user or " +
"tenant scoping; any signed-in user may query it",
},
},
});
r.queryHandler({
name: "metrics:inbox-messages",
schema: z.object({
cursor: z.coerce.number().int().min(0).optional(),
limit: z.number().int().min(1).max(100).optional(),
unreadOnly: z.boolean().optional(),
search: z.string().optional(),
}),
handler: async ({ payload: { cursor, limit, unreadOnly, search } }) => {
const term = search?.trim().toLowerCase() ?? "";
const filtered = INBOX_MESSAGES.filter(
(m) =>
(unreadOnly !== true || m.unread) &&
(term === "" ||
m.sender.toLowerCase().includes(term) ||
m.subject.toLowerCase().includes(term)),
);
const start = cursor ?? 0;
const pageSize = limit ?? 6;
const rows = filtered.slice(start, start + pageSize);
const nextCursor = start + pageSize < filtered.length ? String(start + pageSize) : null;
return { rows, nextCursor };
},
access: {
openToAll: {
reason:
"demo dashboard widget: returns static canned data with no per-user or " +
"tenant scoping; any signed-in user may query it",
},
},
});
r.queryHandler({
name: "metrics:upcoming-events",
schema: z.object({}),
handler: async (_query, ctx) => ({
rows: [
{
primary: demoText("widgets:dashboard:demo-event-rate-adjustment", ctx.locale),
trailing: demoMonthYear(2026, 8, ctx.locale),
},
{
primary: demoText("widgets:dashboard:demo-event-savings-contract", ctx.locale),
trailing: demoMonthYear(2026, 10, ctx.locale),
},
],
}),
access: {
openToAll: {
reason:
"demo dashboard widget: returns static canned data with no per-user or " +
"tenant scoping; any signed-in user may query it",
},
},
});
r.queryHandler({
name: "metrics:goal-progress",
schema: z.object({}),
handler: async (_query, ctx) => ({
rows: [
{
label: demoText("widgets:dashboard:demo-goal-mortgage", ctx.locale),
value: openAmount(42000, ctx.locale),
fraction: 0.71,
},
{
label: demoText("widgets:dashboard:demo-goal-car-loan", ctx.locale),
value: openAmount(3200, ctx.locale),
fraction: 0.92,
},
{ label: { i18nKey: "widgets:dashboard:demo-cap-users" }, used: 10, limit: 10 },
{ label: { i18nKey: "widgets:dashboard:demo-cap-projects" }, used: 4, limit: null },
],
}),
access: {
openToAll: {
reason:
"demo dashboard widget: returns static canned data with no per-user or " +
"tenant scoping; any signed-in user may query it",
},
},
});
r.translations({ keys: WIDGETS_I18N });
r.nav({
id: "widgets",
label: "widgets:nav.widgets",
parent: "gallery:nav:styleguide",
screen: "widgets:screen:widgets",
icon: "layout-grid",
order: 20,
});
r.nav({
id: "widgets-forms",
label: "widgets:nav.widgetsForms",
parent: "gallery:nav:styleguide",
screen: "widgets:screen:widgets-forms",
icon: "clipboard-list",
order: 21,
});
r.nav({
id: "widgets-dashboard",
label: "widgets:nav.widgetsDashboard",
parent: "gallery:nav:styleguide",
screen: "widgets:screen:widgets-dashboard",
icon: "gauge",
order: 22,
});
});

Open /widgets-dashboard beside the custom catalog to compare the two paths. The dashboard has no page component of its own.

Every screen type accepts visibleWhen: { query, field, eq }. The renderer runs the query and shows the screen only when field equals eq, also when someone opens the URL directly. Otherwise it renders the screen named in fallback, or a standard notice. The gate only affects the UI, so the handlers still enforce access. Top-level dashboard panels (stat, stat-group, chart, list, feed, progress list) take the same visibleWhen. Their gate query also receives the screen filter and the time range, and the panel stays hidden while the gate loads. A stat inside a stat group cannot have its own gate, so gate the group instead. A stat group can add a subtitle under its label, and stat groups, charts, lists, feeds and progress lists take span: "half" or span: "full":

{
kind: "stat-group",
id: "billing-kpis",
label: "shop:dashboard.billing",
subtitle: "shop:dashboard.billingHint",
span: "half",
visibleWhen: { query: "shop:query:plan-status", field: "hasBilling", eq: true },
stats: [/* … */],
}

The field list per screen and panel type is in packages/types/src/screen.ts. See also r.screen and Metrics and dashboard data.

A row action, related list row action or toolbar action with kind: "drawer" opens an actionForm of the same feature in a 480 px drawer on the right. The list or detail behind it stays mounted and refreshes after a successful submit. params prefills the form from the clicked row:

rowActions: [
{
kind: "drawer",
id: "adjust-rent",
label: "tenancy.actions.adjust-rent",
screen: "adjust-rent", // actionForm id
params: { pick: ["id"] },
},
],

Closing the drawer with unsaved input asks before it discards anything. Long forms stay pages: use a navigate action and an entityEdit screen.

entityList, projectionList, projectionDetail, entityEdit and actionForm fill the shell height by default. The body scrolls inside, the toolbar stays on top and the pager or form footer stays pinned at the bottom. Set fillHeight: false on the screen to get page scrolling back. Custom screens and direct DataTable or Form usage are not affected.

The framework reference has the details: screen layout (dimensions, drawer actions, layout props) and theming (surfaces, status tones, fonts).

Use a type: "custom" screen when a declarative screen cannot express the interaction. Add an inline allowlist tag with a reason:

// kumiko-lint-ignore app-feature-structure interactive calculator (browser engine), no declarative fit
r.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.

  • Use theme tokens for colors. Use bg-primary or text-status-ok, and set brand colors with defineAppTheme (see Make it yours). Raw palette classes fail kumiko-guard-ui.
  • Use t("…") keys for user-visible text. A hardcoded string fails the same guard, in a custom screen as well as in a declarative one.
  • Put units in the label. Use t("…Summe (€)"), not a separate € or % element next to the field.
  • Keep calculations in a pure, tested function in lib/. Do not put the calculation inside the .tsx component.

The styleguide sample renders every widget on one page, it is both the visual reference and the e2e surface for the kit:

The widget catalog: stat cards, uptime and response-time charts, status badges, the repayment-model switch, a drawer, an inbox list and the form/comparison/calculator demos
Terminal window
cd samples/apps/styleguide && bun dev # → http://localhost:4180/widgets

The catalog page composes the widgets directly in a custom screen:

// Visual catalog of the widget kit — every section shows one widget with
// static data. Also serves as the e2e render surface (content.spec).
import { useLocale, usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
import {
AiTextArea,
AiTextField,
BooleanField,
ChoiceCards,
CollapsibleSection,
ComparisonTable,
DateField,
DetailList,
Drawer,
EmptyState,
FloatingPanel,
InfinityList,
MiniStat,
ModeSwitch,
MoneyField,
PercentField,
PlanCard,
PlanGrid,
ProgressBar,
RangeField,
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
ResultPanel,
ResultTable,
SectionCard,
SelectField,
StatCard,
StatusBadge,
StatusBarChart,
TextareaField,
TextField,
TimeseriesChart,
useDraft,
} from "@cosmicdrift/kumiko-renderer/web";
import { Wallet } from "lucide-react";
import { type ReactNode, useEffect, useMemo, useState } from "react";
import { euro, percent } from "../lib/format";
import { InboxReaderExamples } from "./InboxReaderExamples";
import { VariantsDemo, VariantsRoundTwoDemo } from "./VariantsDemo";
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 t = useTranslation();
const locale = useLocale().locale();
const [mode, setMode] = useState<"annuity" | "fixed">("annuity");
const [provider, setProvider] = useState<"direct" | "reseller">("direct");
const [drawerOpen, setDrawerOpen] = useState(false);
const [belowHeaderDrawerOpen, setBelowHeaderDrawerOpen] = useState(false);
const [floatingPanelOpen, setFloatingPanelOpen] = useState(false);
const { Button } = usePrimitives();
const uptime = useMemo(
() =>
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: t("widgets:catalog:uptime-day", { n: i + 1 }),
})),
[t],
);
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={t("widgets:catalog:portfolio")}
value={euro(92753, locale)}
sub={t("widgets:catalog:portfolio-sub")}
delta={{ value: percent(2.1, locale), direction: "up", tone: "positive" }}
spark={[3, 5, 4, 7, 6, 9, 11, 10]}
/>
<StatCard
label={t("widgets:catalog:remaining-debt")}
value={euro(184000, locale)}
tone="warn"
trend={t("widgets:catalog:remaining-debt-trend")}
/>
<MiniStat label={t("widgets:catalog:interest-rate")} value={percent(3.1, locale)} />
<MiniStat
label={t("widgets:catalog:rate")}
value={euro(890, locale)}
tone="positive"
emphasize
/>
</div>
<SectionCard
title={t("widgets:catalog:uptime")}
subtitle={t("widgets:catalog:uptime-subtitle")}
action={<StatusBadge tone="ok">{t("widgets:catalog:operational")}</StatusBadge>}
>
<StatusBarChart
ariaLabel={t("widgets:catalog:uptime-aria")}
entries={uptime}
startLabel={t("widgets:catalog:uptime-start")}
endLabel={t("widgets:catalog:uptime-end")}
/>
</SectionCard>
<SectionCard
title={t("widgets:catalog:response-time")}
subtitle={t("widgets:catalog:response-time-subtitle")}
>
<TimeseriesChart
points={RESPONSE_TIMES}
windowStartMs={0}
windowEndMs={24 * 60 * 60 * 1000}
ariaLabel={t("widgets:catalog:response-time-aria")}
axisLabels={{
start: t("widgets:catalog:24h-ago"),
mid: t("widgets:catalog:12h-ago"),
end: t("widgets:catalog:now"),
}}
/>
<div className="mt-4">
<TimeseriesChart
points={RESPONSE_TIMES}
windowStartMs={0}
windowEndMs={24 * 60 * 60 * 1000}
ariaLabel={t("widgets:catalog:response-time-p95-aria")}
referenceLines={[
{ value: 180, label: t("widgets:catalog:response-time-p95"), tone: "warn" },
]}
/>
</div>
</SectionCard>
<SectionCard
title={t("widgets:catalog:status-tones")}
action={<ProgressBar value={0.65} className="w-40" />}
>
<div className="flex flex-wrap gap-2">
<StatusBadge tone="ok">{t("widgets:catalog:status-operational")}</StatusBadge>
<StatusBadge tone="warn">{t("widgets:catalog:status-degraded")}</StatusBadge>
<StatusBadge tone="bad">{t("widgets:catalog:status-partial-outage")}</StatusBadge>
<StatusBadge tone="critical">{t("widgets:catalog:status-major-outage")}</StatusBadge>
<StatusBadge tone="muted">{t("widgets:catalog:status-maintenance")}</StatusBadge>
</div>
</SectionCard>
<div className="flex h-12 w-64 items-stretch" data-testid="progress-stretch-host">
<ProgressBar value={0.5} testId="progress-stretch-bar" />
</div>
<SectionCard
title={t("widgets:catalog:repayment-model")}
action={
<ModeSwitch
value={mode}
onChange={setMode}
options={[
{ value: "annuity", label: t("widgets:catalog:mode-annuity") },
{ value: "fixed", label: t("widgets:catalog:mode-fixed") },
]}
/>
}
>
<DetailList
rows={[
{
label: t("widgets:catalog:model"),
value:
mode === "annuity"
? t("widgets:catalog:mode-annuity")
: t("widgets:catalog:mode-fixed"),
},
{ label: t("widgets:catalog:nominal-rate"), value: percent(3.1, locale) },
{
label: t("widgets:catalog:status"),
value: <StatusBadge tone="ok">{t("widgets:catalog:active")}</StatusBadge>,
},
]}
/>
</SectionCard>
<SectionCard title={t("widgets:catalog:choice-cards")}>
<ChoiceCards
value={provider}
onChange={setProvider}
ariaLabel={t("widgets:catalog:choice-cards")}
options={[
{
value: "direct",
title: t("widgets:catalog:choice-direct"),
description: t("widgets:catalog:choice-direct-description"),
aside: "9,00 €",
},
{
value: "reseller",
title: t("widgets:catalog:choice-reseller"),
description: t("widgets:catalog:choice-reseller-description"),
aside: "9,90 €",
},
]}
/>
</SectionCard>
<CollapsibleSection title={t("widgets:catalog:advanced-settings")}>
<EmptyState
title={t("widgets:catalog:no-extra-repayments-title")}
description={t("widgets:catalog:no-extra-repayments-description")}
/>
</CollapsibleSection>
<SectionCard
title={t("widgets:catalog:drawer")}
action={<Button onClick={() => setDrawerOpen(true)}>{t("widgets:catalog:open")}</Button>}
>
<DetailList
rows={[
{
label: t("widgets:catalog:status"),
value: drawerOpen
? t("widgets:catalog:open-status")
: t("widgets:catalog:closed-status"),
},
]}
/>
</SectionCard>
<Drawer
open={drawerOpen}
onOpenChange={setDrawerOpen}
title={t("widgets:catalog:drawer-message-title")}
description="William Smith · 09:34"
footer={
<>
<Button variant="secondary" onClick={() => setDrawerOpen(false)}>
{t("widgets:catalog:cancel")}
</Button>
<Button onClick={() => setDrawerOpen(false)}>{t("widgets:catalog:save")}</Button>
</>
}
// The footer's Cancel button already closes the drawer, so the
// header X would be a second way to do the same thing.
showCloseButton={false}
testId="drawer-demo"
>
<p className="text-sm">Hi team, just a reminder about our meeting tomorrow at 10 AM.</p>
</Drawer>
<SectionCard
title={t("widgets:catalog:drawer-below-header")}
action={
<Button testId="drawer-below-header-open" onClick={() => setBelowHeaderDrawerOpen(true)}>
{t("widgets:catalog:open-below-header")}
</Button>
}
>
<DetailList
rows={[
{
label: t("widgets:catalog:status"),
value: belowHeaderDrawerOpen
? t("widgets:catalog:open-status")
: t("widgets:catalog:closed-status"),
},
]}
/>
</SectionCard>
<Drawer
open={belowHeaderDrawerOpen}
onOpenChange={setBelowHeaderDrawerOpen}
variant="flush"
belowHeader
title={t("widgets:catalog:drawer-below-header-title")}
footer={
<Button onClick={() => setBelowHeaderDrawerOpen(false)}>
{t("widgets:catalog:cancel")}
</Button>
}
showCloseButton={false}
testId="drawer-below-header-demo"
>
<p className="text-sm">
Panel top sits at the header's bottom edge, panel bottom sits at the viewport's bottom
edge — this footer button must stay visible.
</p>
</Drawer>
<SectionCard
title={t("widgets:catalog:floating-panel")}
action={
<Button onClick={() => setFloatingPanelOpen(true)}>
{t("widgets:catalog:floating-panel-open")}
</Button>
}
>
<DetailList
rows={[
{
label: t("widgets:catalog:status"),
value: floatingPanelOpen
? t("widgets:catalog:open-status")
: t("widgets:catalog:closed-status"),
},
]}
/>
</SectionCard>
{floatingPanelOpen && (
<FloatingPanel
title={t("widgets:catalog:floating-panel")}
headerActions={
<Button variant="secondary" onClick={() => setFloatingPanelOpen(false)}>
{t("widgets:catalog:floating-panel-close")}
</Button>
}
storageKey="styleguide:floating-panel"
testId="floating-panel-demo"
>
<p className="text-sm">{t("widgets:catalog:floating-panel-body")}</p>
</FloatingPanel>
)}
<InboxDemo />
<InboxReaderExamples />
<FinancingCalculatorDemo />
<FormFieldsDemo />
<ComparisonDemo />
<AiTextDemo />
<PlanCatalogDemo />
<VariantsDemo />
<VariantsRoundTwoDemo />
</div>
);
}
// PlanCard/PlanGrid: a billing-plan catalog (current plan, a purchasable
// plan, and a plan whose price failed to load) plus one compact card for a
// one-off purchase (e.g. a credit pack) — compact drops the feature list
// and description for a denser, single-row layout.
function PlanCatalogDemo(): ReactNode {
const t = useTranslation();
return (
<SectionCard title={t("widgets:catalog:plans")} subtitle={t("widgets:catalog:plans-subtitle")}>
<PlanGrid testId="plan-catalog-demo">
<PlanCard
testId="plan-card-starter"
title={t("widgets:catalog:plan-starter")}
description={t("widgets:catalog:plan-starter-description")}
price={{ amount: "$9", period: t("widgets:catalog:plan-per-month") }}
features={[
t("widgets:catalog:plan-feature-projects"),
t("widgets:catalog:plan-feature-support"),
]}
current
/>
<PlanCard
testId="plan-card-pro"
title={t("widgets:catalog:plan-pro")}
description={t("widgets:catalog:plan-pro-description")}
price={{ amount: "$29", period: t("widgets:catalog:plan-per-month") }}
features={[
t("widgets:catalog:plan-feature-projects-unlimited"),
t("widgets:catalog:plan-feature-support-priority"),
t("widgets:catalog:plan-feature-analytics"),
]}
cta={{ label: t("widgets:catalog:plan-choose"), onClick: () => {} }}
/>
<PlanCard
testId="plan-card-enterprise"
title={t("widgets:catalog:plan-enterprise")}
description={t("widgets:catalog:plan-enterprise-description")}
price={null}
features={[t("widgets:catalog:plan-feature-sso"), t("widgets:catalog:plan-feature-sla")]}
cta={{
label: t("widgets:catalog:plan-contact-sales"),
onClick: () => {},
disabled: true,
}}
/>
</PlanGrid>
<div className="mt-4 max-w-xs">
<PlanCard
testId="plan-card-credits"
variant="compact"
title={t("widgets:catalog:plan-credits")}
price={{ amount: "$5" }}
cta={{ label: t("widgets:catalog:plan-buy"), onClick: () => {} }}
/>
</div>
</SectionCard>
);
}
type InboxMessage = {
readonly id: string;
readonly sender: string;
readonly subject: string;
readonly snippet: string;
readonly unread: boolean;
};
type InboxPage = { readonly rows: readonly InboxMessage[]; readonly nextCursor: string | null };
// Inbox-like scroll list: filter (unread toggle + search) + row action
// (archive, no-op like the other demo buttons here). Clicking a row shows
// the message in the right panel — split via resizable, like the list/read
// pair in real mail clients (a draggable handle instead of an OS resize
// grip).
function InboxDemo(): ReactNode {
const t = useTranslation();
const { Button } = usePrimitives();
const [unreadOnly, setUnreadOnly] = useState(false);
const [search, setSearch] = useState("");
// Debounced: InfinityList refetches whenever its payload identity changes
// (payloadKey = JSON.stringify(payload)), so wiring `search` directly in
// would refetch on every keystroke — a loading flash per character.
const [debouncedSearch, setDebouncedSearch] = useState("");
useEffect(() => {
const id = setTimeout(() => setDebouncedSearch(search), 250);
return () => clearTimeout(id);
}, [search]);
const [selected, setSelected] = useState<InboxMessage | null>(null);
return (
<SectionCard
title={t("widgets:catalog:inbox")}
action={
<div className="flex items-center gap-2">
<ModeSwitch
value={unreadOnly ? "unread" : "all"}
onChange={(v) => setUnreadOnly(v === "unread")}
options={[
{ value: "all", label: t("widgets:catalog:filter-all") },
{ value: "unread", label: t("widgets:catalog:filter-unread") },
]}
/>
<Button variant="secondary" onClick={() => {}}>
{t("widgets:catalog:mark-all-read")}
</Button>
</div>
}
>
<TextField
label={t("widgets:catalog:search")}
id="inbox-search"
name="inbox-search"
value={search}
onChange={setSearch}
placeholder={t("widgets:catalog:search-placeholder")}
/>
<ResizablePanelGroup orientation="horizontal" className="mt-3 h-96 rounded-lg border">
<ResizablePanel defaultSize="35" minSize="25" className="overflow-y-auto">
<InfinityList<InboxPage, InboxMessage>
query="widgets:query:metrics:inbox-messages"
payload={{ unreadOnly, search: debouncedSearch }}
pageSize={6}
rows={(data) => data.rows}
nextCursor={(data) => data.nextCursor}
rowId={(row) => row.id}
testId="inbox-demo"
live={true}
renderRow={(row) => (
<div
className={`flex items-start justify-between gap-4 border-b px-3 py-2 last:border-b-0 hover:bg-muted/50 ${
selected?.id === row.id ? "bg-muted" : ""
}`}
>
<button
type="button"
onClick={() => setSelected(row)}
className="min-w-0 flex-1 text-left"
>
<div className={row.unread ? "font-semibold" : ""}>
{row.sender} · {row.subject}
</div>
<div className="truncate text-sm text-muted-foreground">{row.snippet}</div>
</button>
<Button variant="secondary" size="sm" onClick={() => {}}>
{t("widgets:catalog:archive")}
</Button>
</div>
)}
/>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel defaultSize="65" minSize="30" className="overflow-y-auto p-4">
{selected === null ? (
<EmptyState
title={t("widgets:catalog:no-message-selected-title")}
description={t("widgets:catalog:no-message-selected-description")}
/>
) : (
<div>
<div className="font-semibold">{selected.sender}</div>
<div className="text-sm text-muted-foreground">{selected.subject}</div>
<p className="mt-4 text-sm">{selected.snippet}</p>
</div>
)}
</ResizablePanel>
</ResizablePanelGroup>
</SectionCard>
);
}
// Ghost-text completion + correct/translate/rewrite toolbar. The server
// handler here is a hand-rolled demo feature (ai-text-demo.ts, canned
// strings), not the real enterprise feature — the public framework must not
// import the private enterprise package. The title field is deliberately pre-filled with
// a value wider than the box (ghost-overlay scroll sync), the note textarea
// with more lines than visible (vertical scroll sync).
function AiTextDemo(): ReactNode {
const t = useTranslation();
const [title, setTitle] = useState(() => t("widgets:catalog:long-title-demo"));
const [note, setNote] = useState(() =>
Array.from({ length: 12 }, (_, i) => t("widgets:catalog:long-note-line", { n: i + 1 })).join(
"\n",
),
);
return (
<SectionCard
title={t("widgets:catalog:ai-text")}
subtitle={t("widgets:catalog:ai-text-subtitle")}
>
<AiTextField
id="ai-text-title"
name="title"
label={t("widgets:catalog:title")}
value={title}
onChange={setTitle}
/>
<AiTextArea
id="ai-text-note"
name="note"
label={t("widgets:catalog:note")}
value={note}
onChange={setNote}
rows={4}
/>
</SectionCard>
);
}
// Field widgets for non-number types (select/date/text/boolean/textarea) —
// wrap the same usePrimitives input kinds as 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 t = useTranslation();
const { draft, field } = useDraft<FieldsDraft>(FIELDS_DEFAULTS);
const { Button } = usePrimitives();
return (
<SectionCard title={t("widgets:catalog:form-fields")}>
<TextField
label={t("widgets:catalog:name")}
{...field("name")}
placeholder={t("widgets:catalog:name-placeholder")}
/>
<SelectField
label={t("widgets:catalog:state")}
{...field("land")}
options={[
{ value: "NW", label: t("widgets:catalog:state-nw") },
{ value: "BY", label: t("widgets:catalog:state-by") },
]}
/>
<DateField label={t("widgets:catalog:date")} {...field("datum")} />
<BooleanField label={t("widgets:catalog:include-broker")} {...field("aktiv")} />
<RangeField
label={t("widgets:catalog:call-rate", { n: draft.abruf })}
{...field("abruf")}
min={0}
max={100}
step={5}
/>
<TextareaField label={t("widgets:catalog:note")} {...field("notiz")} rows={3} />
<div className="flex items-center gap-2">
<Button size="sm" variant="secondary" onClick={() => {}}>
{t("widgets:catalog:small")}
</Button>
<Button onClick={() => {}}>{t("widgets:catalog:standard")}</Button>
</div>
</SectionCard>
);
}
// Transposed comparison (row = metric, column = variant), best value
// highlighted — for scenario/offer comparisons.
function ComparisonDemo(): ReactNode {
const t = useTranslation();
const locale = useLocale().locale();
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={t("widgets:catalog:comparison")}>
<ComparisonTable
columns={scenarios}
columnHeader={(s) => s.name}
columnKey={(s) => s.name}
metricLabel={t("widgets:catalog:metric")}
metrics={[
{
label: t("widgets:catalog:monthly-rate"),
value: (s) => euro(s.rate, locale),
bestIndex: () => minIndex((s) => s.rate),
},
{
label: t("widgets:catalog:total-interest"),
value: (s) => euro(s.interest, locale),
bestIndex: () => minIndex((s) => s.interest),
},
]}
/>
</SectionCard>
);
}
// Live-input calculator: useDraft → pure calculation → ResultPanel/ResultTable.
// Shows the form kit carries the apps' calculator pattern without custom CSS.
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 t = useTranslation();
const locale = useLocale().locale();
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;
return (
<div className="grid gap-4 lg:grid-cols-2">
<SectionCard title={t("widgets:catalog:financing")}>
<MoneyField label={t("widgets:catalog:loan")} {...field("sum")} required />
<PercentField label={t("widgets:catalog:nominal-rate")} {...field("interest")} required />
<PercentField label={t("widgets:catalog:repayment")} {...field("repayment")} />
</SectionCard>
<ResultPanel
title={t("widgets:catalog:result")}
empty={!ready}
emptyText={t("widgets:catalog:enter-loan-and-interest")}
rows={[
{ label: t("widgets:catalog:loan"), value: euro(draft.sum ?? 0, locale) },
{ label: t("widgets:catalog:monthly-rate"), value: euro(rate, locale), emphasize: true },
]}
>
<ResultTable
columns={[
{
header: t("widgets:catalog:tranche"),
cell: (r: { label: string; rate: number }) => r.label,
},
{
header: t("widgets:catalog:rate"),
align: "right",
cell: (r) => euro(r.rate, locale),
},
]}
rows={[{ label: t("widgets:catalog:bank-loan"), rate }]}
rowKey={(r) => r.label}
/>
</ResultPanel>
</div>
);
}