Skip to content

UI Renderer Architektur

E2E-Testing: Der Renderer muss den Marker-Vertrag aus e2e-test-contract.md einhalten, damit die auto-generierten Playwright-Specs grün werden können (data-testid="field-*", data-testid="field-error-*", Save-Button-Label, role="table").

Grundprinzip

Das Framework trennt was gerendert wird von wie es gerendert wird.

ui-core (pure TS) → Logik, State, View-Models
renderer/[name]/ → JSX, DOM/Native, Darstellung
  • ui-core hat keine React/RN Dependency — pure TypeScript
  • Ein Renderer ist ein Core Feature das sich via r.registerRenderer() anmeldet
  • Features bleiben wie sie sind (r.entity, r.crud, r.screen, r.nav)
  • Default Renderer: renderer-react (React Web ohne SSR)

Package-Struktur

packages/
framework/ ← Engine, Pipeline, DB (wie heute)
ui-core/ ← View-Models, Form-Controller, Hooks-Logik
renderer-react/ ← React Web Feature
renderer-expo/ ← Expo Feature
primitives-shadcn/ ← Default Web Primitives
primitives-paper/ ← Default Native Primitives
apps/
server/ ← Hono API (shared, ein Deploy)
web/ ← importiert framework + ui-core + renderer-react
mobile/ ← importiert framework + ui-core + renderer-expo

Ein Renderer pro Build. Kein Runtime-Switch, kein Platform.select():

apps/web/app.ts
createApp({
features: [orderFeature, dashboardFeature, rendererReact],
primitives: shadcnPrimitives,
});
// apps/mobile/app.ts
createApp({
features: [orderFeature, rendererExpo],
primitives: paperPrimitives,
});

Zwei Modi

Modus 1: Config-driven (Standard CRUD)

Feature definiert Screens als Datenstruktur. Core berechnet fertige View-Models. Renderer malt nur noch.

// Feature definiert:
r.screen({
id: "orderEdit",
type: "entityEdit",
layout: {
sections: [{
title: "orders:section.details",
columns: 2,
fields: ["identifier", "client", "status"],
}],
},
});
// Core berechnet daraus ein View-Model:
{
type: "entityEdit",
title: "Auftrag bearbeiten",
sections: [{
title: "Details",
columns: 2,
fields: [
{ key: "identifier", label: "Nr.", type: "text", value: "A-001", readonly: true },
{ key: "client", label: "Kunde", type: "relation", value: 42, display: "Firma ABC" },
{ key: "status", label: "Status", type: "select", value: "open", options: [...] },
],
}],
form: { isDirty: false, isUnchanged: true, errors: [], submit: fn, reset: fn },
actions: [{ id: "save", label: "Speichern" }, { id: "delete", label: "Löschen", variant: "danger" }],
}
// Renderer bekommt das View-Model, macht JSX draus — null Logik

Modus 2: Custom Screen (Dashboard, Kanban, etc.)

Dev schreibt freies JSX. Framework liefert Hooks als Bausteine.

r.screen({
id: "dashboard",
type: "custom",
renderer: {
react: WebDashboard,
native: MobileDashboard, // optional
},
});
function WebDashboard() {
const { data } = useCommand("analytics:query:revenue", { period: "month" });
const user = useCurrentUser();
const { navigate } = useNav();
const theme = useTheme();
// Ab hier: freies React, sein Code
const [filter, setFilter] = useState("week");
return (
<div>
<RevenueChart data={data} filter={filter} />
<Button onPress={() => navigate("orders:screen:order-list")}>Aufträge</Button>
</div>
);
}

Logik teilen zwischen Renderern

Business-Logik ist pure TS — wiederverwendbar:

features/dashboard/
logic.ts ← Pure TS: Berechnungen, Filter, Transformationen
screens/
web.tsx ← importiert logic.ts, rendert mit Web-Components
native.tsx ← importiert logic.ts, rendert mit Native-Components

Kein Zwang beides zu bauen — wenn nur Web gebraucht wird, nur Web.

Renderer-Struktur

renderer/react/
index.ts ← r.registerRenderer("react", { ... })
render-list.tsx ← entityList View-Model → HTML
render-edit.tsx ← entityEdit View-Model → Formular
render-field.tsx ← einzelnes Feld → Input/Select/Toggle
render-nav.tsx ← Nav-Baum → Sidebar/Tabs
hooks/
use-command.ts ← React Hook Wrapper um Core Query-Executor
use-form.ts ← React Hook Wrapper um Core Form-Controller
use-nav.ts ← React Hook Wrapper um Core Navigation
use-theme.ts ← Theme Context

Registrierung

export default defineFeature("renderer-react", (r) => {
r.registerRenderer("react", {
entityList: renderList,
entityEdit: renderEdit,
field: renderField,
nav: renderNav,
resolveAsset: (qn) => `/assets/${scope}/${filename}`,
});
});

Primitives: Contract statt Library

Der Renderer definiert den Vertrag, der App-Dev waehlt die Implementierung.

// ui-core: Interface
export type PrimitivesContract = {
Button: ComponentType<ButtonProps>;
TextInput: ComponentType<TextInputProps>;
NumberInput: ComponentType<NumberInputProps>;
Select: ComponentType<SelectProps>;
Toggle: ComponentType<ToggleProps>;
DatePicker: ComponentType<DatePickerProps>;
Modal: ComponentType<ModalProps>;
Toast: { success(msg: string): void; error(msg: string): void };
Badge: ComponentType<BadgeProps>;
Card: ComponentType<CardProps>;
Icon: ComponentType<IconProps>;
};

Framework liefert @kumiko/primitives-shadcn und @kumiko/primitives-paper als Defaults. Austauschbar — solange der Contract erfuellt ist.

usePrimitives() fuer plattform-agnostische Custom Screens. Oder direkt shadcn importieren fuer Web-only — bewusste Entscheidung des Devs.

Hooks und State

Eigene schlanke Hooks. Kein TanStack Query — das Framework hat SSE fuer Realtime-Updates.

// ui-core: pure TS
export function buildQueryFn(qn: string, payload: unknown): () => Promise<Result>
// Renderer: duenner React Hook Wrapper
export function useCommand<T>(qn: string, payload: unknown) {
const [state, setState] = useState<{ data?: T; loading: boolean; error?: Error }>({
loading: true,
});
useEffect(() => {
buildQueryFn(qn, payload)()
.then(data => setState({ data, loading: false }))
.catch(error => setState({ error, loading: false }));
}, [qn, JSON.stringify(payload)]);
return state;
}

Invalidation ueber SSE:

User klickt Save → useMutation("tasks:write:task:update", changes)
→ Server verarbeitet
→ SSE pushed "system:event:task:updated"
→ useCommand hoert das → refetch

Form-Controller

Pure TypeScript im Core. Kein React, kein DOM.

State

createFormController({
schema: ZodType, // vom Handler — Client-Validierung
entity: EntityDefinition, // Feld-Typen, Select-Optionen
screenDef: ScreenDef, // Layout, Conditional Fields
initial: Record<string, unknown>,
user: SessionUser,
})
→ {
values, // aktuelle Werte
changes, // nur was sich vs. initial geaendert hat
isDirty, // User hat Felder angefasst (touched)
isUnchanged, // values === initial (keine echten Aenderungen)
isSubmitting,
errors, // Zod validation errors pro Feld
fields, // berechnete Feld-Metadaten (type, visible, readonly, required)
setField(name, value),
validate(),
submit(),
reset(),
}

Dirty vs. Unchanged

isDirty = mindestens ein Feld angefasst
isUnchanged = alle values identisch mit initial
Initial: { name: "Jochen" }
User tippt "Joch": isDirty=true, changes={ name: "Joch" }, isUnchanged=false
User tippt "Jochen": isDirty=true, changes={}, isUnchanged=true

Changes-Only Submit

Commands tragen nur Aenderungen:

setField(name, value) {
values[name] = value;
touched.add(name);
if (value !== initial[name]) {
changes[name] = value;
} else {
delete changes[name]; // zurueck auf Original → kein Change
}
}
// Submit: { id: 42, version: 3, changes: { title: "Neu" } }

Conditional Fields

visible, readonly, required bei jedem setField neu evaluiert. Invisible Felder bei Validierung ausgeschlossen.

Relations: Lines/Sub-Datensaetze

Screen-Definition

r.screen({
id: "orderEdit",
type: "entityEdit",
layout: {
sections: [
{ title: "order:section.details", fields: ["customer", "date"] },
{
title: "order:section.stops",
relation: "stops",
feature: "orderStops", // eigenes Feature liefert Lines-Renderer
},
],
},
});

Form-Controller mit Lines

Sub-Controller pro Zeile, automatisch:

controller.lines.stops = [
{ id: 1, values: {...}, changes: { time: "14:00" }, isDirty: true },
{ id: 2, values: {...}, changes: {}, isDirty: false },
{ id: null, values: { address: "Berlin" }, isNew: true },
]
controller.deletedLines.stops = [3]

Batch Submit

UI schickt unsortierten Sack voll Commands. Server sortiert automatisch anhand der Relations:

POST /api/batch
{
commands: [
{ type: "stops:write:stop:delete", payload: { id: 3 } },
{ type: "stops:write:stop:create", payload: { orderId: 42, address: "Berlin" } },
{ type: "order:write:order:update", payload: { id: 42, version: 3, changes: {...} } },
{ type: "stops:write:stop:update", payload: { id: 1, version: 1, changes: {...} } },
]
}

Server sortiert: Parent zuerst → Creates → Updates → Deletes zuletzt. Alles in einer DB-Transaktion. Einer schlaegt fehl → alles Rollback.

Cross-Feature UI Components

// Feature registriert:
defineFeature("addressAutocomplete", (r) => {
r.uiComponent("addressPicker", {
react: GoogleAddressPicker,
native: NativeAddressPicker,
});
});
// Anderes Feature nutzt es statt Standard-Select:
r.screen({
id: "orderEdit",
type: "entityEdit",
layout: {
sections: [{
fields: [
"customer",
{
field: "pickupAddressId",
component: "address-autocomplete:component:address-picker",
},
],
}],
},
});

Was einmal, was pro Renderer?

WasRenderer-spezifisch?
Config (Sections, Fields, Columns, Conditions)Nein
Field Renderer (Badge, Custom Widget)Ja — mit Fallback
UI Component (Picker, Map, Chart)Ja — pro Renderer
Custom Screen (Dashboard, Kanban)Ja — mit geteilter Logic
Shared Logic (Berechnungen, Filter)Nein

Feature-Renderer Overrides

defineFeature("kanban", (r) => {
r.entity("ticket", ticketEntity);
r.crud("ticket");
r.screen({
id: "ticketList",
type: "entityList",
renderer: {
react: KanbanBoard,
// native fehlt → Standard entityList Renderer
},
});
});

Fallback-Kette:

  1. Feature liefert renderer-spezifischen Renderer → nutze den
  2. Keinen → Standard-Renderer des aktiven Renderers
  3. Standard fehlt → Boot Error

Design und Theming

Core Design Tokens

// ui-core: Theme Contract
export type ThemeContract = {
colors: {
primary: ColorScale;
surface: { background: string; card: string; border: string };
semantic: { success: string; warning: string; error: string };
};
spacing: { xs: number; sm: number; md: number; lg: number; xl: number };
radius: { sm: number; md: number; lg: number };
typography: {
heading: { fontFamily: string; fontWeight: string };
body: { fontFamily: string; fontWeight: string };
};
};

Core liefert Defaults. Renderer entscheidet wie Tokens angewendet werden.

Tailwind als Styling-Layer

Beide Renderer nutzen Tailwind — Web via Tailwind CSS, Native via NativeWind. Gleiche Klassen-Syntax, gleiche Design Tokens, unterschiedliche Compile-Targets.

// ui-core exportiert Tokens als Tailwind-Config
export const tailwindPreset = {
theme: {
colors: { primary: "var(--color-primary)", ... },
spacing: { xs: "4px", sm: "8px", md: "16px", ... },
borderRadius: { sm: "4px", md: "8px", ... },
screens: { compact: "0px", medium: "600px", expanded: "1200px" },
},
};
// renderer-react: tailwind.config.ts
import { tailwindPreset } from "@kumiko/ui-core";
export default { presets: [tailwindPreset] };
// renderer-expo: tailwind.config.js (NativeWind)
import { tailwindPreset } from "@kumiko/ui-core";
export default { presets: [tailwindPreset] };

Feature-Devs schreiben auf beiden Plattformen die gleichen Klassen:

// Plattform-agnostische Component
function OrderCard({ order }) {
const { Card, Text } = usePrimitives();
return (
<Card className="p-md bg-surface rounded-md medium:p-lg">
<Text className="font-bold text-primary">{order.identifier}</Text>
</Card>
);
}
  • Web: echtes Tailwind CSS → CSS Classes, gebundelt
  • Native: NativeWind → StyleSheet.create zur Compile-Zeit

Tenant-Overrides via CSS Variables

Web: Tenant kann Tokens ueberschreiben ohne Rebuild:

// Tenant-Config setzt:
theme.colors.primary = "#ff6600"
// Renderer injected:
<style>:root { --color-primary: #ff6600; }</style>
// Alle Tailwind-Klassen die "var(--color-primary)" nutzen → updaten automatisch

Native: Tokens sind zur Compile-Zeit gebunden. Tenant-spezifisches Theming via Runtime-Override in useTheme().

Responsive Breakpoints

// Shared Breakpoints ueberall:
compact → 0-599px (Phone)
medium → 600-1199px (Tablet, schmales Browser-Fenster)
expanded → 1200px+ (Desktop)
// Tailwind-Prefix:
<div className="grid-cols-1 medium:grid-cols-2 expanded:grid-cols-3" />

Theme als Feature

Dev kann Core-Tokens ueberschreiben und eigene hinzufuegen:

defineFeature("myTheme", (r) => {
// Core-Tokens ueberschreiben
r.theme({
colors: { primary: { 500: "#ff6600" } },
});
// Eigene Tokens hinzufuegen
r.themeTokens({
statusColors: { pending: "#f59e0b", delivered: "#10b981" },
});
});
// Andere Features:
defineFeature("delivery", (r) => {
r.requires("myTheme");
// useTheme().statusColors.delivered
});

Tenant-Overrides via Config Feature. Dark/Light Mode via User-Config.

Core: Screen-Graph

r.screen({ id: "orderList", type: "entityList", ... });
r.screen({ id: "orderEdit", type: "entityEdit", ... });
r.nav({ id: "orders", screen: "orderList", icon: "list" });

Core berechnet Nav-Baum + Screen-Beziehungen (List → Detail). Renderer mapped:

  • React: URL Routing (/t/{tenant}/orders/:id)
  • Expo: Stack/Tab Navigation

Shell als Feature

Layout ist austauschbar:

// Framework liefert Standard-Shells:
createApp({ features: [..., shellSidebar] });
// Dev baut eigenes Layout:
defineFeature("shell-workspaces", (r) => {
r.registerShell({
web: { renderer: { react: WorkspaceShell } },
});
});
createApp({ features: [..., shellWorkspaces] }); // statt shellSidebar

Screen Params

Custom Screens definieren ihre Parameter typsicher:

r.screen({
id: "revenue",
type: "custom",
params: z.object({ period: z.string().optional() }),
renderer: { react: RevenueDetail },
});
navigate("dashboard:screen:revenue", { period: "month" })

Icons

Lucide als Standard — funktioniert in React (lucide-react) und Expo (lucide-react-native). Gleiche Namen, gleiche API.

Typsicher via Union Type:

// ui-core: TypeScript Check fuer Icon-Namen
export type IconName =
| "list" | "plus" | "trash-2" | "pencil" | "search"
| "filter" | "settings" | "user" | "menu" | "x"
| "check" | "chevron-left" | "chevron-right"
| "chart-bar" | "bell" | "mail"
// ...
;
r.nav({ icon: "list", ... }); // ✓
r.nav({ icon: "batman", ... }); // ✗ TypeScript Error

Assets

Features registrieren Assets mit QN:

defineFeature("branding", (r) => {
r.asset("logo", "logo.svg");
r.asset("onboarding-video", "intro.mp4");
});
// → "branding:asset:logo"
// → "branding:asset:onboarding-video"

Boot-Validation prueft ob Datei existiert. Usage via Hook:

const logo = useAsset("branding:asset:logo");
// Renderer-React: resolved zu URL
// Renderer-Expo: resolved zu Metro require

Der Renderer hat einen resolveAsset im Contract der QN → plattform-spezifischen Pfad mapped.

Boot-Validation

Beim Boot prueft das Framework:

CheckError/Warning
Custom Screen ohne Renderer fuer aktive PlattformError wenn requires, Warning wenn optionalRequires
UI Component ohne Renderer fuer aktive PlattformError wenn requires, Warning wenn optionalRequires
Screen Extension Target existiert nichtError
Asset-Datei existiert nichtError
Icon-Name nicht im IconName TypeTypeScript Compile Error
Primitives Contract nicht erfuelltTypeScript Compile Error

Deploy

API Server (Bun + Hono) → Container, eine Instanz
Web App (React) → Static Build oder eigener Server
Mobile App (Expo) → EAS Build, App Store

Kein Blocker: Shared Code (Zod Schemas, Entity-Defs, QNs) ist pure TS. ui-core hat keine server-only Dependencies. Universelle Dependencies Regel schuetzt vor Metro-inkompatiblen Paketen.

Localization: Sprache, Datum, Zahlen, Geld

Locale-Kette

User-Config (hoechste Prio, z.B. "de")
↓ nicht gesetzt?
Tenant-Config (z.B. "de-AT")
↓ nicht gesetzt?
App-Default (z.B. "de")
↓ nicht gesetzt?
Fallback ("en")
// ui-core: resolved das aktive Locale
function resolveLocale(opts: {
userConfig?: string,
tenantConfig?: string,
appDefault?: string,
fallback: string,
}): string {
return opts.userConfig ?? opts.tenantConfig ?? opts.appDefault ?? opts.fallback;
}

Locale wird via Config Feature verwaltet — scope: "user" fuer User-Override, scope: "tenant" fuer Tenant-Default. Existierender Mechanismus, keine neue Infrastruktur.

Anzeige (Formatierung)

Intl.NumberFormat und Intl.DateTimeFormat — in jeder JS Runtime verfuegbar:

// Zahlen
new Intl.NumberFormat(locale, { minimumFractionDigits: 2 }).format(1234.56)
// de-DE → "1.234,56" en-US → "1,234.56"
// Geld
new Intl.NumberFormat(locale, { style: "currency", currency: "EUR" }).format(1234.56)
// de-DE → "1.234,56 €" en-US → "€1,234.56"
// Datum
new Intl.DateTimeFormat(locale, { dateStyle: "medium" }).format(new Date())
// de-DE → "14.04.2026" en-US → "Apr 14, 2026"

Eingabe (Parsing)

NumberInput und DatePicker Primitives bekommen das Locale fuer korrektes Input-Parsing:

// User tippt "1234,56" (deutsch) → intern gespeichert als 1234.56 (Number)
// User tippt "1,234.56" (englisch) → intern gespeichert als 1234.56 (Number)

Der Renderer stellt das aktive Locale via useLocale() bereit. Ein Hook, eine Quelle — fliesst in Formatierung, Eingabe und i18next.

Strings

i18next mit feature-basierten Namespaces (existiert schon):

const { t } = useTranslation("orders");
t("section.details") // → "Details" (de) / "Details" (en)

Zusammenspiel mit QN

// Navigation
navigate("orders:screen:order-list")
// Cross-Feature Component
{ field: "client", component: "clients:component:client-badge" }
// Screen Extension Target
r.screenExtension({ target: { screen: "orders:screen:order-edit" } })
// Assets
useAsset("branding:asset:logo")
// Theme Tokens
useTheme().feature("delivery").statusColors