UI Renderer Architektur
E2E-Testing: Der Renderer muss den Marker-Vertrag aus
e2e-test-contract.mdeinhalten, 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-Modelsrenderer/[name]/ → JSX, DOM/Native, Darstellungui-corehat 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-expoEin Renderer pro Build. Kein Runtime-Switch, kein Platform.select():
createApp({ features: [orderFeature, dashboardFeature, rendererReact], primitives: shadcnPrimitives,});
// apps/mobile/app.tscreateApp({ 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 LogikModus 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-ComponentsKein 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 ContextRegistrierung
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: Interfaceexport 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 TSexport function buildQueryFn(qn: string, payload: unknown): () => Promise<Result>
// Renderer: duenner React Hook Wrapperexport 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 → refetchForm-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 angefasstisUnchanged = alle values identisch mit initialInitial: { name: "Jochen" }User tippt "Joch": isDirty=true, changes={ name: "Joch" }, isUnchanged=falseUser tippt "Jochen": isDirty=true, changes={}, isUnchanged=trueChanges-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?
| Was | Renderer-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:
- Feature liefert renderer-spezifischen Renderer → nutze den
- Keinen → Standard-Renderer des aktiven Renderers
- Standard fehlt → Boot Error
Design und Theming
Core Design Tokens
// ui-core: Theme Contractexport 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-Configexport 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.tsimport { 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 Componentfunction 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 automatischNative: 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.
Navigation
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 shellSidebarScreen 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-Namenexport 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 ErrorAssets
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 requireDer Renderer hat einen resolveAsset im Contract der QN → plattform-spezifischen Pfad mapped.
Boot-Validation
Beim Boot prueft das Framework:
| Check | Error/Warning |
|---|---|
| Custom Screen ohne Renderer fuer aktive Plattform | Error wenn requires, Warning wenn optionalRequires |
| UI Component ohne Renderer fuer aktive Plattform | Error wenn requires, Warning wenn optionalRequires |
| Screen Extension Target existiert nicht | Error |
| Asset-Datei existiert nicht | Error |
| Icon-Name nicht im IconName Type | TypeScript Compile Error |
| Primitives Contract nicht erfuellt | TypeScript Compile Error |
Deploy
API Server (Bun + Hono) → Container, eine InstanzWeb App (React) → Static Build oder eigener ServerMobile App (Expo) → EAS Build, App StoreKein 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 Localefunction 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:
// Zahlennew Intl.NumberFormat(locale, { minimumFractionDigits: 2 }).format(1234.56)// de-DE → "1.234,56" en-US → "1,234.56"
// Geldnew Intl.NumberFormat(locale, { style: "currency", currency: "EUR" }).format(1234.56)// de-DE → "1.234,56 €" en-US → "€1,234.56"
// Datumnew 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
// Navigationnavigate("orders:screen:order-list")
// Cross-Feature Component{ field: "client", component: "clients:component:client-badge" }
// Screen Extension Targetr.screenExtension({ target: { screen: "orders:screen:order-edit" } })
// AssetsuseAsset("branding:asset:logo")
// Theme TokensuseTheme().feature("delivery").statusColors