Agent Skill
2/7/2026

kiteui-development

This skill should be used when the user asks about KiteUI, Kotlin Multiplatform UI development, or mentions KiteUI-specific components and patterns

K
kf7mxe
0GitHub Stars
1Views
npx skills add kf7mxe/Brisingr

SKILL.md

Namekiteui-development
DescriptionThis skill should be used when the user asks about KiteUI, Kotlin Multiplatform UI development, or mentions KiteUI-specific components and patterns

name: KiteUI Development description: This skill should be used when the user asks about KiteUI, Kotlin Multiplatform UI development, or mentions KiteUI-specific components and patterns version: 1.0.0

KiteUI Version 7 Development Skill

You are an expert in KiteUI, a Kotlin Multiplatform UI framework that uses native view components and fine-grained reactivity inspired by Solid.js.

The information here is for KiteUI 7 - KiteUI 6 is very similar, but it uses - between modifiers and their views, like this: card - col {}. V6 also differs in that pages are expected to return the views they created, whereas V7 does not.

Core Principles

Design Philosophy

  • Web-first: Generates small binaries (~0.77MB vs Compose's ~12MB) with excellent web performance
  • Native views: Uses platform's native UI components (not canvas-based rendering)
  • Fine-grained reactivity: Only updates what needs to be updated, no full-tree reconciliation
  • Semantic theming: Style based on meaning (important, danger, warning) not direct colors
  • URL-based navigation: Deep linking and routing built-in via @Routable annotations
  • Beautiful by default: Ugliness should take effort, not prettiness

Supported Platforms

  • Android
  • iOS
  • Web (JavaScript)
  • JVM (in progress)

Project Structure

Typical KiteUI Project Layout

my-app/
├── build.gradle.kts
├── src/
│   ├── commonMain/
│   │   └── kotlin/
│   │       └── com/example/myapp/
│   │           ├── App.kt              # Main application entry
│   │           ├── pages/              # Page components
│   │           ├── widgets/            # Reusable components
│   │           └── models/             # Data models
│   ├── androidMain/
│   ├── iosMain/
│   └── jsMain/
└── settings.gradle.kts

Dependencies

// In build.gradle.kts
repositories {
    maven("https://lightningkite-maven.s3.us-west-2.amazonaws.com")
    mavenCentral()
}

dependencies {
    api("com.lightningkite.kiteui:library:<version>")
}

Recommended Imports

For sanity and readability, use liberal star imports for KiteUI, Reactive, and database packages:

// Reactive packages - always star import
import com.lightningkite.reactive.core.*
import com.lightningkite.reactive.context.*
import com.lightningkite.reactive.extensions.*

// KiteUI view packages - always star import
import com.lightningkite.kiteui.views.*
import com.lightningkite.kiteui.views.direct.*
import com.lightningkite.kiteui.views.l2.*

// Lightning Server database (for modification blocks)
import com.lightningkite.lightningdb.*

// Navigation
import com.lightningkite.kiteui.navigation.*

// Coroutines (for launch blocks and async operations)
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.toList

// Datetime operations
import kotlinx.datetime.*

// Field paths (auto-generated by @GenerateDataClassPaths)
import com.yourapp.name
import com.yourapp.email
import com.yourapp.organization
// etc.

Why star imports?

  • Reduces import clutter significantly
  • Standard practice in KiteUI projects
  • No ambiguity - these packages are designed for star imports

Creating Pages

Basic Page

@Routable("my-page")
object MyPage : Page {
    override fun ViewWriter.render() {
        col {
            h1("My Page Title")
            text("Content goes here")
        }
    }
}

Note: Older KiteUI 7 versions used render(): ViewModifiable = run { }, but current versions use render() { }.

Page with Parameters

@Routable("user/{userId}")
class UserProfilePage(val userId: String) : Page {
    override fun ViewWriter.render() {
        col {
            h1("User Profile")
            text("User ID: $userId")
        }
    }
}

Page with State

@Routable("counter")
object CounterPage : Page {
    override fun ViewWriter.render() {
        val count = Signal(0)

        col {
            h1("Counter")
            text { ::content { "Count: ${count()}" } }
            button {
                text("Increment")
                action = Action("Increment") {
                    count.value++
                }
            }
        }
    }
}

Note: Action constructor takes a name string, not an Icon. Icons are set separately if needed.

Layout System

Modern Syntax (KiteUI v7+)

CRITICAL: KiteUI v7 uses dot notation (.) for chaining modifiers and containers. The dash operator (-) is DEPRECATED and does not exist in modern KiteUI.

// ✅ CORRECT - Modern v7 syntax
expanding.scrolling.col { }
card.centered.text("Hello")
weight(1f).card.text("Content")

// ❌ WRONG - Old v6 syntax (DO NOT USE)
expanding - scrolling - col { }
card - centered - text("Hello")

Modifiers vs Containers

Modifiers are adjectives that modify the element that comes after them:

  • expanding - Makes element take available space (equivalent to weight(1f))
  • scrolling - Enables vertical scrolling
  • scrollingHorizontally - Enables horizontal scrolling
  • centered - Centers content
  • card - Applies card background/styling
  • padded - Adds default padding
  • weight(N.f) - Flex-grow and flex-shrink in one
  • Size modifiers: sizeConstraints(), positioning modifiers, etc.

Containers are the actual layout elements that hold children:

  • col - Vertical layout (stacks children vertically)
  • row - Horizontal layout (arranges children side by side)
  • frame - Z-stack / FrameLayout (stacks children on top of each other)
  • rowCollapsingToColumn(breakpoint) - Responsive layout that becomes column below breakpoint

The Pattern: modifier.modifier.container { children }

// Modifiers chain together, then end with a container
expanding.scrolling.card.col {
    // expanding = modifier (takes available space)
    // scrolling = modifier (enables vertical scroll)
    // card = modifier (applies card styling)
    // col = container (vertical layout)
}

CSS Equivalents

For those familiar with CSS, here are the mappings:

Layout Containers:

col                → display: flex; flex-direction: column
row                → display: flex; flex-direction: row
frame              → position: relative (with absolute children)
gap = 1.rem        → gap: 1rem (flexbox/grid gap)

Modifiers:

expanding          → flex: 1 (flex-grow: 1; flex-shrink: 1)
weight(2f)         → flex: 2
scrolling          → overflow-y: auto
scrollingHorizontally → overflow-x: auto
centered           → display: flex; align-items: center; justify-content: center
padded             → padding: var(--spacing)
sizeConstraints(width = 20.rem) → width: 20rem

Frame Positioning (like CSS absolute positioning):

centered           → position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%)
atTop              → position: absolute; top: 0; left: 0; right: 0
atBottom           → position: absolute; bottom: 0; left: 0; right: 0
atStart            → position: absolute; left: 0; top: 0; bottom: 0
atEnd              → position: absolute; right: 0; top: 0; bottom: 0
atTopStart         → position: absolute; top: 0; left: 0
atBottomEnd        → position: absolute; bottom: 0; right: 0

Chaining Pattern:

expanding.scrolling.card.col { }

Similar to utility-first CSS (like Tailwind):

<div class="flex-1 overflow-y-auto bg-card flex flex-col">

Critical Scrolling Rule

⚠️ NOTHING scrolls without explicit instruction. You must use scrolling or scrollingHorizontally modifiers to enable scrolling.

// ❌ This will NOT scroll, even if content overflows
col {
    repeat(100) { text("Item $it") }
}

// ✅ This will scroll vertically
scrolling.col {
    repeat(100) { text("Item $it") }
}

// ✅ This will scroll horizontally
scrollingHorizontally.row {
    repeat(100) { card.text("$it") }
}

CSS equivalent:

/* ❌ Without overflow, content just overflows container */
.container { display: flex; flex-direction: column; }

/* ✅ With overflow-y, content scrolls */
.container { display: flex; flex-direction: column; overflow-y: auto; }

Layout Containers

Column (Vertical Layout)

col {
    gap = 1.rem  // Space between children
    text("First")
    text("Second")
    text("Third")
}

// With scrolling
scrolling.col {
    repeat(100) { text("Item $it") }
}

// Takes available space
expanding.col {
    text("Top")
    text("Bottom")
}

Row (Horizontal Layout)

row {
    gap = 0.5.rem
    card.text("Left")
    expanding.card.text("Center (expands)")
    card.text("Right")
}

// Equal-width items using weight
row {
    weight(1f).card.text("A")
    weight(1f).card.text("B")
    weight(1f).card.text("C")
}

// Using expanding (equivalent to weight(1f))
row {
    card.text("A")
    expanding.card.text("B")  // Takes available space
    card.text("C")
}

Frame (Z-Stack / Stacked Layout)

frame {
    // Children are stacked on top of each other (Z-axis)
    image { source = Resources.background }
    centered.text("Overlay Text")
    atTopStart.text("Top Left")
    atBottomEnd.text("Bottom Right")
}

Frame positioning modifiers:

  • centered - Center of the frame
  • atTop, atBottom, atStart, atEnd - Edge alignment
  • atTopStart, atTopEnd, atBottomStart, atBottomEnd - Corner alignment

Responsive Layout

// Becomes column when screen width < 70rem
rowCollapsingToColumn(70.rem) {
    weight(1f).card.text("Sidebar")
    weight(3f).card.text("Main Content")
}

Common Components

Text Components

h1("Main Heading")
h2("Subheading")
h3("Smaller heading")
h4("Even smaller")
h5("Very small")
h6("Smallest")
text("Regular text")
subtext("Smaller, muted text")

Text with Dynamic Content

val name = Signal("World")
text { ::content { "Hello, ${name()}!" } }

Buttons

// Basic button
button {
    text("Click Me")
    onClick {
        println("Clicked!")
    }
}

// Button with action
button {
    text("Save")
    action = Action("Save", Icon.save) {
        delay(1000)  // Simulated async work
        saveData()
    }
}

// Themed button
important.button {
    text("Important Action")
    onClick { /* ... */ }
}

Text Inputs

val email = Signal("")

textInput {
    hint = "Enter your email"
    keyboardHints = KeyboardHints.email
    content bind email
}

// With action (like pressing Enter)
textInput {
    hint = "Search"
    content bind searchQuery
    action = Action("Search", Icon.search) {
        performSearch(searchQuery())
    }
}

Text Area (Multi-line)

val notes = Signal("")

textArea {
    hint = "Enter notes"
    content bind notes
}

Checkbox

val isChecked = Signal(false)

checkbox {
    checked bind isChecked
}

// With label
field("Accept Terms") {
    checkbox {
        checked bind acceptedTerms
    }
}

Switch (Toggle)

val isEnabled = Signal(false)

switch {
    checked bind isEnabled  // Note: uses 'checked' not 'enabled'
}

Radio Buttons

Use the .equalTo() extension for clean radio button binding:

val selectedOption = Signal<Int?>(null)

val options = listOf(
    null to "Never",
    1 to "After 1 day",
    7 to "After 7 days"
)

options.forEach { (value, label) ->
    button {
        row {
            radioButton {
                // ✅ Best: Use .equalTo() for automatic bidirectional binding
                checked bind selectedOption.equalTo(value)
            }
            space()
            text(label)
        }
        onClick {
            selectedOption.value = value
        }
    }
}

How .equalTo() works:

  • Returns true when selectedOption() == value
  • When set to true, automatically sets selectedOption.value = value
  • Much cleaner than manual remember { }.withWrite { } pattern

Radio Toggle Buttons (Preferred)

For styled radio selection groups, use radioToggleButton instead of manual button+radioButton combinations:

val selectedActivity = Signal(0)
val activities = listOf("Endurance", "Hill Climb", "HIIT", "Recovery")

row {
    activities.forEachIndexed { index, label ->
        expanding.radioToggleButton {
            centered.text { content = label }
            checked bind selectedActivity.equalTo(index)
        }
    }
}

With icons:

val activities = listOf(
    AppIcons.bike to "Endurance",
    AppIcons.mountain to "Hill Climb",
    AppIcons.bolt to "HIIT",
    AppIcons.fitness to "Recovery"
)

row {
    activities.forEachIndexed { index, (icon, label) ->
        expanding.radioToggleButton {
            col {
                centered.icon { source = icon }
                centered.subtext { content = label }
            }
            checked bind selectedActivity.equalTo(index)
        }
    }
}

Key Points:

  • radioToggleButton automatically uses SelectedSemantic when checked, UnselectedSemantic when unchecked
  • Style via theme overrides on SelectedSemantic and UnselectedSemantic
  • Use expanding modifier for equal-width buttons in a row
  • Cannot be deselected by clicking again (radio behavior)
  • For toggleable buttons (checkbox behavior), use toggleButton instead

Form State Management with Suspend Functions

Important: Property setters and binding callbacks cannot call suspend functions. When building forms that save to the server, use local Signals and an Action:

// Local state for form editing
val localIsPrivate = Signal(false)
val localMuteNotifications = Signal(false)

// Initialize from server data when dialog opens
reactive {
    if (showDialog()) {
        localIsPrivate.value = room().isPrivate
        localMuteNotifications.value = room().muteNotifications
    }
}

// Save action
val saveSettings = Action("Save Settings") {
    val s = currentSession() ?: return@Action
    s.api.room.update(room().copy(
        isPrivate = localIsPrivate.value,
        muteNotifications = localMuteNotifications.value
    ))
    showDialog.value = false
}

// In UI
switch { checked bind localIsPrivate }
checkbox { checked bind localMuteNotifications }
button {
    text("Save")
    action = saveSettings
}

Select (Dropdown)

val options = listOf("Option 1", "Option 2", "Option 3")
val selected = Signal("Option 1")

select {
    bind(selected, options) { it }
}

Images

image {
    source = Resources.myImage
    scaleType = ImageScaleType.Crop
    description = "Alt text for accessibility"
}

Icons

// Pattern 1: Direct icon with description (most common)
icon(Icon.home, "Home")
icon(Icon.settings, "Settings")

// Pattern 2: DSL block with properties
icon {
    source = Icon.home
    description = "Home"
}

// Pattern 3: Icon in a row with text
row {
    icon { source = Icon.settings }
    text { content = "Settings" }
}

Custom Icons from SVG Paths

Create custom icons using Material Design SVG paths. Icons use a viewBox typically 0, -960, 960, 960:

object AppIcons {
    // Use built-in icons when available
    val arrowBack = Icon.arrowBack
    val settings = Icon.settings
    val check = Icon.done

    // Create custom icons from Material Design SVG paths
    val lockOpen = Icon(
        1.5.rem, 1.5.rem, 0, -960, 960, 960,
        listOf("M240-640h360v-80q0-50-35-85t-85-35q-50 0-85 35t-35 85h-80q0-83 58.5-141.5T480-920q83 0 141.5 58.5T680-720v80h40q33 0 56.5 23.5T800-560v400q0 33-23.5 56.5T720-80H240q-33 0-56.5-23.5T160-160v-400q0-33 23.5-56.5T240-640Z")
    )

    val camera = Icon(
        1.5.rem, 1.5.rem, 0, -960, 960, 960,
        listOf("M480-260q75 0 127.5-52.5T660-440q0-75-52.5-127.5T480-620q-75 0-127.5 52.5T300-440q0 75 52.5 127.5T480-260Z")
    )
}

// Usage
icon { source = AppIcons.lockOpen }

Best Practice: Centralize custom icons in an AppIcons object for consistency and reusability.

Links

// Internal navigation link
link {
    text("Go to Settings")
    to = { SettingsPage }
}

// External link
externalLink {
    text("Visit Website")
    to = "https://example.com"
}

Separators and Space

col {
    text("Above")
    separator()  // Horizontal line
    text("Below")
    space()      // Empty space
}

Modifiers

Modifiers chain using dot notation (.) and apply from left to right. Each modifier returns a ViewWriter that the next modifier or container can build upon.

Sizing

// Set specific size
sizeConstraints(width = 20.rem, height = 10.rem).text("Fixed size")

// Set only width or height
sizeConstraints(width = 15.rem).text("Fixed width")

// Min/max constraints
sizeConstraints(minWidth = 10.rem, maxWidth = 30.rem).text("Constrained")

Layout Positioning

// In a row
expanding.text("Takes available space")
weight(2f).text("Takes 2x space relative to weight(1f)")

// In a frame
centered.text("Centered")
atTop.text("Top")
atBottom.text("Bottom")
atStart.text("Start (left in LTR)")
atEnd.text("End (right in LTR)")
atTopStart.text("Top left")
atBottomEnd.text("Bottom right")

Equal Width Cards in a Row

Use expanding on each item to make them equal width:

row {
    listOf("Option A", "Option B", "Option C").forEach { label ->
        expanding.card.button {
            centered.text { content = label }
            onClick { /* ... */ }
        }
    }
}

Important: You cannot use expanding. with conditional expressions directly. Apply expanding. to each branch:

// ❌ Won't work - conditionals can't be chained with dot notation
expanding.if (isSelected) { selected.card.button { } } else { card.button { } }

// ✅ Correct pattern - apply expanding inside each branch
if (isSelected) {
    expanding.selected.card.button { /* ... */ }
} else {
    expanding.card.button { /* ... */ }
}

Fixed Bottom Button Pattern

Put action buttons outside the scrolling container so they're always visible:

col {
    // Header
    row { /* ... */ }

    // Scrollable content
    expanding.padded.scrolling.col {
        // Content that may overflow
        card.col { /* ... */ }
        card.col { /* ... */ }
    }

    // Fixed bottom button (always visible)
    padded.important.button {
        centered.text { content = "Submit" }
        onClick { /* ... */ }
    }
}

Key Points:

  • Use expanding on the scrolling container to fill available space
  • Put the action button AFTER the scrolling container
  • The button stays fixed at the bottom regardless of scroll position

Responsive Horizontal Scrolling

For rows with many options that don't fit on small screens, use scrollingHorizontally:

// Option cards that scroll horizontally on mobile
subtext { content = "Select Time" }
scrollingHorizontally.row {
    listOf("6 AM", "7 AM", "8 AM", "9 AM", "10 AM").forEach { time ->
        sizeConstraints(minWidth = 4.rem).card.button {
            centered.text { content = time }
            onClick { /* ... */ }
        }
    }
}

Key Points:

  • Use scrollingHorizontally.row { } to make row contents scrollable
  • Add sizeConstraints(minWidth = X.rem) to prevent cards from getting too small
  • Don't use expanding with scrollingHorizontally - cards should have fixed/minimum widths
  • On desktop, all items show; on mobile, users can scroll horizontally

When to use each approach:

  • expanding - When you want items to fill available width (good for 2-3 items)
  • scrollingHorizontally + sizeConstraints(minWidth) - When you have 4+ items that may not fit on mobile
  • rowCollapsingToColumn(breakpoint) - When you want items to stack vertically on small screens

Scrolling

// Vertical scrolling
scrolling.col {
    repeat(100) { text("Item $it") }
}

// Horizontal scrolling
scrollingHorizontally.row {
    repeat(100) { card.text("$it") }
}

Padding

padded.text("Has default padding")

Visibility

val show = Signal(true)

col {
    reactiveScope {
        if (show()) {
            text("Conditionally shown")
        }
    }
}

Themes (Semantic Styling)

// Common semantics
card.text("In a card")
important.button { text("Important action") }
danger.button { text("Destructive action") }
warning.text("Warning message")
critical.button { text("Critical action") }
fieldTheme.textInput { /* ... */ }

State Management (Reactivity)

Signal - Mutable Reactive State

val count = Signal(0)

// Read value (in reactive context)
text { ::content { "Count: ${count()}" } }

// Write value
count.value = 5
count.value++

Property - Another name for Signal

val email = Property("")
textInput { content bind email }

Constant - Immutable Reactive Value

val title: Reactive<String> = Constant("Welcome")

Shared - Computed Reactive Value

val firstName = Signal("John")
val lastName = Signal("Doe")
val fullName = remember { "${firstName()} ${lastName()}" }

text { ::content { fullName() } }

RememberSuspending - Async Computed Reactive Value

Use rememberSuspending for async data loading (preferred over Signal + launch pattern):

// ✅ CORRECT: Use rememberSuspending for async data
val booth = rememberSuspending {
    val session = currentSession() ?: return@rememberSuspending null
    val res = session.reservations.get(reservationId).await() ?: return@rememberSuspending null
    session.booths.get(res.booth).await()
}

val location = rememberSuspending {
    val b = booth() ?: return@rememberSuspending null
    val session = currentSession() ?: return@rememberSuspending null
    session.locations.get(b.location).await()
}

// UI automatically shows loading state until data is ready
text { ::content { "Booth: ${booth()?.name}" } }
text { ::content { "Location: ${location()?.name}" } }

❌ AVOID: Signal + launch pattern

// DON'T DO THIS - harder to manage, no automatic loading states
val booth = Signal<Booth?>(null)
launch {
    booth.value = session.booths.get(boothId).await()
}

Key Benefits:

  • Automatic loading states in UI (shows spinner while loading)
  • Reactive recomputation when dependencies change
  • Cleaner code with less boilerplate
  • Proper error handling with nullable returns

LazyProperty - Computed with Override

val calculatedValue = LazyProperty { baseValue() * 2 }

// Can override
calculatedValue.value = 100

// Reset to calculation
calculatedValue.reset()

LateInitProperty - Initially Unset

val userData = LateInitProperty<UserData>()

// Components show loading until set
userData.value = fetchedUserData

// Can unset
userData.unset()

Reactive Scope

text {
    // Automatically re-runs when dependencies change
    ::content { "Total: ${price() * quantity()}" }
}

ReactiveScope Block

⚠️ WARNING: reactiveScope adds duplicate views on every rerun!

reactiveScope {
    // Re-runs when signals inside change
    if (showAdvanced()) {
        advancedSettings()  // ⚠️ This adds NEW views each time, doesn't replace!
    }
}

When to use:

  • Rarely! Almost always better to use shownWhen, swapView, or ::property { } bindings
  • If you must use it, ALWAYS use clearChildren() first

Better alternatives:

// ✅ BEST: Use shownWhen modifier for conditional visibility
card.col {
    h3 { content = "Advanced Settings" }
    // ... advanced content ...
}.shownWhen { showAdvanced() }  // Automatically shown/hidden reactively

// ✅ BEST: Use swapView for animated transitions between views
swapView(remember { if (showAdvanced()) "advanced" else "simple" }) { mode ->
    when (mode) {
        "advanced" -> advancedSettings()
        "simple" -> simpleSettings()
    }
}

// ✅ GOOD: Use reactive bindings for automatic loading states
text { ::content { userData()?.name ?: "Loading..." } }

// ❌ AVOID: Manual reactiveScope creates duplicate views
reactiveScope {
    val data = userData()
    if (data != null) {
        text { content = data.name }  // Adds new text view on every userData change!
    }
}

// ⚠️ LAST RESORT: If you MUST use reactiveScope, ALWAYS clear children first
reactiveScope {
    clearChildren()  // ⚠️ CRITICAL: Remove old views before adding new ones
    if (showAdvanced()) {
        advancedSettings()
    }
}

// Example: Dynamic lists with reactiveScope (when forEach doesn't work)
col {
    reactiveScope {
        clearChildren()  // ⚠️ MUST call this to prevent duplicates

        booth()?.lockIds?.forEach { lockInfo ->
            button {
                text { content = lockInfo.name }
                onClick { unlock(lockInfo.id) }
            }
        }
    }
}

Recommended patterns (in order of preference):

  1. shownWhen - For simple show/hide of sections
  2. swapView - For animated transitions between different views
  3. ::property { } - For automatic loading states and reactive text
  4. forEach - For dynamic lists (when it works - may have issues with complex types)
  5. reactiveScope + clearChildren() - Last resort when the above don't work

⚠️ CRITICAL: Always call clearChildren() as the first line in reactiveScope blocks that add views!

ForEach - Reactive Lists

Use forEach to render dynamic lists that update when the underlying data changes:

val items = Signal(listOf("A", "B", "C"))

col {
    forEach(items) { item ->
        card.text(item)
    }
}

Dynamic lists from async data:

// Load data reactively
val booth = rememberSuspending {
    val session = currentSession() ?: return@rememberSuspending null
    val res = session.reservations.get(reservationId).await() ?: return@rememberSuspending null
    session.booths.get(res.booth).await()
}

// Render list of doors with forEach + shared
col {
    h3 { content = "Available Doors" }

    forEach(remember { booth()?.lockIds ?: emptyList() }) { lockInfo ->
        button {
            row {
                icon { source = AppIcons.lockOpen }
                text { content = lockInfo.name }
            }
            action = Action("Unlock ${lockInfo.name}") {
                unlockDoor(lockInfo.id)
                toast("Unlocked ${lockInfo.name}")
            }
        }
    }
}

Key points:

  • forEach automatically updates when the list changes
  • Use remember { } to transform reactive data before rendering
  • Empty list handling: booth()?.lockIds ?: emptyList() gracefully handles null
  • Each item gets its own scope for actions and state

Two-way Binding

val text = Signal("")

textInput {
    content bind text  // Bidirectional binding
}

Navigation

Navigate to Page

button {
    text("Go to Settings")
    onClick {
        pageNavigator.navigate(SettingsPage)
    }
}

Navigate with Parameters

onClick {
    pageNavigator.navigate(UserProfilePage("user123"))
}

Navigate Back

onClick {
    pageNavigator.goBack()
}

Replace Current Page

onClick {
    pageNavigator.replace(LoginPage)
}

Using Links

link {
    text("Settings")
    to = { SettingsPage }
}

Theming

Using Built-in Themes

// Available themes
Theme.clean()
Theme.flat()
Theme.flat2()
Theme.m3()
Theme.shadCnLike("theme-name")

Applying Semantics

// These are modifiers that apply semantic meaning
important.button { text("Save") }      // Important action
danger.button { text("Delete") }       // Destructive action
warning.text("Warning message")        // Warning
critical.button { text("Override") }   // Critical action
card.col { /* ... */ }                 // Card background
fieldTheme.textInput { /* ... */ }     // Field styling

Theme Rules

  1. Switching themes causes a background/card
  2. Switching to the same theme doesn't create a card (use card explicitly)
  3. Apply theme switches to containers, not individual elements
  4. Typical places: button, col, row, frame

Custom Semantics

Simple Custom Semantic:

data object LinkButtonSemantic : Semantic("lnkbtn") {
    override fun default(theme: Theme): ThemeAndBack = theme.copy(
        id = key,
        semanticOverrides = semanticOverridesOf(
            HoverSemantic to {
                it.copy(
                    id = "hov",
                    font = it.font.copy(underline = true)
                ).withoutBack
            }
        )
    ).withoutBack
}

// Extension for convenient access
@ViewModifierDsl3
val ViewWriter.linkButton: ViewWriter get() = LinkButtonSemantic.onNext

// Usage
linkButton.button {
    text("Click me")  // Underlines on hover
}

Parametric Semantic:

data class Background(
    val pad: Boolean = false,
    val background: (Theme) -> Paint
) : Semantic("mnbck") {
    override fun default(theme: Theme): ThemeAndBack =
        theme.copy(
            id = key,
            background = background(theme)
        ).let {
            if (pad) it.withBack else it.withBackNoPadding
        }
}

// Usage
Background(true) { it.background.closestColor() }.onNext.text("Solid bg")

Custom Icon Extensions:

// In a centralized Icons.kt file
val Icon.Companion.bug: Icon
    get() = Icon(
        width = 1.5.rem, height = 1.5.rem,
        viewBoxMinX = 0, viewBoxMinY = -960,
        viewBoxWidth = 960, viewBoxHeight = 960,
        pathDatas = listOf("M480-200q66 0 113-47t47-113...")
    )

val Icon.Companion.feature: Icon
    get() = Icon(
        width = 1.5.rem, height = 1.5.rem,
        viewBoxMinX = 0, viewBoxMinY = -960,
        viewBoxWidth = 960, viewBoxHeight = 960,
        pathDatas = listOf("m320-240 160-122 160 122...")
    )

// Usage
icon(Icon.bug, "Debug")
icon(Icon.feature, "Feature Request")

Working with Forms

Basic Form

val email = Signal("")
val password = Signal("")

col {
    field("Email") {
        fieldTheme.textInput {
            hint = "your@email.com"
            keyboardHints = KeyboardHints.email
            content bind email
        }
    }

    field("Password") {
        fieldTheme.textInput {
            hint = "Password"
            keyboardHints = KeyboardHints.password
            content bind password
        }
    }

    important.button {
        text("Sign In")
        onClick {
            signIn(email(), password())
        }
    }
}

Field with Label

field("Username") {
    textInput {
        hint = "Enter username"
        content bind username
    }
}

Dialogs and Overlays

Toast Notification

onClick {
    toast("Operation successful!")
}

Alert Dialog

onClick {
    alert("Are you sure you want to delete this?")
}

Confirm Dialog

onClick {
    val confirmed = confirm("Delete this item?")
    if (confirmed) {
        deleteItem()
    }
}

Custom Dialog

Use dialog { close -> } pattern for modal dialogs:

onClick {
    dialog { close ->  // close callback provided automatically
        card.col {
            h2("Custom Dialog")
            text("Dialog content here")
            row {
                button {
                    text("Cancel")
                    onClick { close() }
                }
                important.button {
                    text("Confirm")
                    onClick {
                        // Do something
                        close()
                    }
                }
            }
        }
    }
}

Benefits:

  • No manual Signal needed to track dialog state
  • Automatic overlay/background handling
  • close() callback provided automatically
  • Cleaner than manual dismissBackground + shownWhen pattern

Bottom Sheet

Bottom sheets are modal overlays that slide up from the bottom of the screen, commonly used for forms and option menus.

onClick {
    openBottomSheet {
        col {
            h3 { content = "Options" }
            subtext { content = "Select an option:" }
            button {
                text { content = "Option 1" }
                onClick { /* ... */ }
            }
            button {
                text { content = "Option 2" }
                onClick { /* ... */ }
            }
        }
    }
}

Closing behavior:

  • User taps outside the sheet → closes automatically
  • User navigates to another page → closes automatically
  • Call dismissBackground() from inside the sheet to close programmatically

Common pattern - Form in bottom sheet:

fun ViewWriter.EditItemSheet(item: Item, onSave: () -> Unit) {
    val nameInput = Signal(item.name)
    val descriptionInput = Signal(item.description)

    col {
        h2 { content = "Edit Item" }

        field("Name") {
            textInput {
                content bind nameInput
            }
        }

        field("Description") {
            textArea {
                content bind descriptionInput
            }
        }

        row {
            button {
                text { content = "Cancel" }
                onClick { dismissBackground() }  // Close sheet
            }
            important.button {
                text { content = "Save" }
                action = Action("Save") {
                    val session = currentSession.await() ?: return@Action
                    session.items[item._id].modify(modification {
                        it.name assign nameInput.value
                        it.description assign descriptionInput.value
                    })
                    onSave()
                    dismissBackground()  // Close sheet after save
                }
            }
        }

        subtext { content = "Tap outside to close" }
    }
}

// Usage
button {
    text { content = "Edit" }
    onClick {
        openBottomSheet {
            EditItemSheet(item) {
                // Refresh list after save
                loadItems()
            }
        }
    }
}

Note: Use openBottomSheet { } (not bottomSheet { }). The function name indicates it opens a new sheet.

Advanced Patterns

Client-Side Caching with ModelCache

CRITICAL: ALWAYS use ModelCache for Lightning Server APIs. This is not a tradeoff - it's a straight improvement in every way. ModelCache handles request optimization, caching, consistency, and WebSocket integration automatically.

Why ModelCache is Mandatory

ModelCache provides:

  • Zero extra network requests: Caches prevent redundant fetches
  • Instant UI consistency: A modification is immediately visible to all views
  • Automatic WebSocket integration: Real-time updates when available, seamless fallback to polling
  • Request batching: Multiple concurrent requests are automatically batched
  • Query caching with smart invalidation: Query results update automatically when individual items change
  • Background polling with staleness tracking: Fresh data without manual refresh logic

Setup: Wrap All Endpoints

class UserSession(
    val api: Api  // Generated API from Lightning Server
) : CachedApi(api) {
    // CachedApi base class automatically wraps all endpoints with ModelCache
    // Each endpoint like api.user becomes a ModelCache<User, Uuid>
}

// Manual setup (if not using CachedApi base class):
class UserSession(
    val uncached: Api,
    val userId: Uuid
) {
    // Wrap each endpoint with ModelCache for client-side caching
    val users: ModelCache<User, Uuid> =
        ModelCache(uncached.user, User.serializer(), log = StoredLog())

    val projects: ModelCache<Project, Uuid> =
        ModelCache(uncached.project, Project.serializer())

    val tasks: ModelCache<Task, Uuid> =
        ModelCache(uncached.task, Task.serializer())
}

How ModelCache Works Under the Hood

Individual Item Caching (cache[id]):

  • Returns a ModelCacheItemReadable<T> with reactive .state property
  • First access triggers background fetch (waits for WebSocket connection if available)
  • Subsequent accesses return cached value instantly (if fresh)
  • Automatically polls for updates at configurable pullFrequency (default: 60 seconds)
  • If WebSocket is active, skips polling and relies on real-time updates
  • All modifications via cache[id].modify() or cache[id].set() update cache instantly

Query Caching (cache.list()):

  • Returns a ModelCacheLimitReadable<T> with reactive .state property
  • Query results are cached with timestamp and requested limit
  • Intelligently reconstructs query results when individual items are modified
  • Example: If you query "all active users" and one user becomes inactive, the cache automatically removes them from the result
  • WebSocket updates automatically invalidate/update matching queries
  • Dynamic limit support: changing .limit property automatically fetches more items if needed

Update Pipeline (how modifications propagate):

  1. Call cache[id].modify(modification) or cache.add(item)
  2. API request sent to server, returns updated item
  3. Updated item flows through newData signal (central update pipeline)
  4. newData triggers:
    • Individual item cache update (lastIndividualValues)
    • Query cache reconstruction (ListReconstructionCalculator)
    • All reactive listeners fire (UI updates automatically)
  5. Result: Every view watching that item or any matching query sees the change instantly

WebSocket Integration (automatic real-time updates):

  • When pullFrequency < 30 seconds, ModelCache prefers WebSocket over polling
  • Cache tracks which conditions are actively being watched
  • Server sends change notifications: {updates: [items], remove: [ids]}
  • Changes flow through same newData pipeline as manual modifications
  • On socket overload (server can't keep up), cache clears everything and refetches (safety mechanism)
  • Fallback: if socket fails to connect within 5 seconds, falls back to immediate fetch + polling

Request Batching:

  • Multiple concurrent cache[id1], cache[id2], cache[id3] calls batch into single query: _id inside [id1, id2, id3]
  • Reduces network round trips from N to 1
  • Queries execute in parallel but don't merge (each query is separate, but concurrent)

Usage Patterns

Individual Item (reactive access):

val userId = Uuid.parse("...")
val user = remember { session.users[userId]() }  // Reactive<User?>

text { ::content { user()?.name ?: "Loading..." } }

Individual Item (with polling configuration):

// Custom staleness and polling
val user = session.users.item(
    id = userId,
    maximumAge = 30.seconds,     // Data valid for 30 seconds
    pullFrequency = 60.seconds   // Poll every minute
)

text { ::content { user()?.name ?: "Loading..." } }

Query (reactive list):

val activeUsers = remember {
    session.users.list(
        query = Query(condition { it.active eq true }),
        maximumAge = 10.seconds,
        pullFrequency = 30.seconds
    )()  // Call operator returns Reactive<List<User>>
}

forEach(activeUsers) { user ->
    text { ::content { user().name } }
}

Modifying cached items:

// Modify via cache - updates all views automatically
launch {
    session.users[userId].modify(
        Modification.assign(User::name, "New Name")
    )
    // All views watching this user see the change instantly
}

// Alternative: set entire value (uses diffing to send only changed fields)
launch {
    val user = session.users[userId].awaitOnce()
    session.users[userId].set(user.copy(name = "New Name"))
}

// Insert new item
launch {
    val newUser = User(_id = Uuid.random(), name = "Alice", ...)
    session.users.add(newUser)
    // Cache updated, all matching queries update automatically
}

Complex reactive composition:

// Self-reference (current user)
val self = remember { session.users[session.userId]()!! }

// Derived reactive values
val currentOrgId = Signal<Uuid?>(null)

// Query based on reactive signal
val myOrganizations = remember {
    val orgIds = self().organizationIds
    session.organizations.list(
        query = Query(condition { it._id inside orgIds })
    )()
}

// Nested reactive (flattened)
val currentOrg = remember {
    currentOrgId()?.let { session.organizations[it]() }
}.flatten()  // Reactive<Reactive<Org?>> -> Reactive<Org?>

Anti-Pattern: Direct API Calls (DO NOT DO THIS)

// WRONG: Bypasses cache, no reactivity, wasted network requests
launch {
    val users = session.api.user.query(Query(...))  // Direct API call
    // Problem: Not cached, not reactive, every call fetches from server
}

// WRONG: Manual cache invalidation needed, UI doesn't update automatically
val users = Signal<List<User>>(emptyList())
launch {
    users.value = session.api.user.query(Query(...))
    // Problem: If another screen modifies a user, this list is stale
}

// RIGHT: Use ModelCache
val users = remember {
    session.users.list(Query(...))()  // Cached, reactive, auto-updates
}

Performance Benefits

Before ModelCache (direct API calls):

  • Same user queried 5 times = 5 network requests
  • User modified in one screen = other screens show stale data until manual refresh
  • No WebSocket support = must implement polling manually
  • Query results don't update when individual items change

After ModelCache:

  • Same user queried 5 times = 1 network request (cached)
  • User modified anywhere = all screens update instantly (unified update pipeline)
  • WebSocket support = real-time updates with zero polling overhead
  • Query results automatically reconstruct when items change

Request Reduction Example:

// Without batching (old approach):
val user1 = api.user.detail(id1)  // Request 1
val user2 = api.user.detail(id2)  // Request 2
val user3 = api.user.detail(id3)  // Request 3
// Total: 3 requests

// With ModelCache batching:
val user1 = cache[id1]()  // \
val user2 = cache[id2]()  //  } Batched into single query: _id in [id1,id2,id3]
val user3 = cache[id3]()  // /
// Total: 1 request

Common Gotchas

Socket Activation Timing: If WebSocket activates AFTER data is cached, you might miss changes. ModelCache checks activation timestamps to avoid applying stale socket updates.

Limited Query Deletions: When items are deleted from a limited query (e.g., limit = 10), the cache doesn't automatically fetch more items to fill the gap. You may end up with 9 items instead of 10 until next poll.

Bulk Modifications: cache.bulkModify() clears ALL caches because it can't know which items were affected. This is intentionally aggressive to avoid serving stale data.

Local Testing Utilities: For tests or optimistic updates, use cache.localInsert(item) or cache.localSignalUpdate() to manipulate cache without API calls. Use with caution - creates local/server divergence until next sync.

Migration Checklist

When migrating existing code to ModelCache:

  1. Wrap all API endpoints in session/cached API layer
  2. Replace direct api.model.query() calls with cache.list().state
  3. Replace direct api.model.detail() calls with cache[id].state
  4. Replace manual signal management with reactive cache properties
  5. Remove manual polling/refresh logic (ModelCache handles it)
  6. Use cache[id].modify() for mutations instead of api.model.modify() + manual cache invalidation
  7. Test WebSocket integration to verify real-time updates work
  8. Configure maximumAge and pullFrequency based on data freshness requirements

Summary

ALWAYS use ModelCache. Never call API endpoints directly from UI code. The only exceptions are:

  • Server-side code (obviously)
  • One-off operations where result is never displayed (e.g., logging events)
  • Operations that don't return data models (e.g., file uploads, health checks)

For everything else, ModelCache is the correct, optimized, production-ready solution.

ModelCache Client-Side API Patterns

When working with ModelCache in KiteUI screens, follow these patterns for proper data loading and modification.

Required Imports

Always include these imports when using ModelCache:

import com.lightningkite.reactive.context.*      // Provides .await() extension
import com.lightningkite.reactive.core.*          // Signal, Reactive, remember
import com.lightningkite.reactive.extensions.*    // Reactive utilities
import kotlinx.coroutines.launch                  // For async operations

// Field paths (generated by @GenerateDataClassPaths)
import com.yourapp.name
import com.yourapp.email
// etc.

Loading Data Pattern

Use Signal<T?>(null) + .get(id).await() for loading individual items:

@Routable("detail/{itemId}")
class DetailScreen(val itemId: Uuid) : Page {
    override fun ViewWriter.render() {
        val item = Signal<Item?>(null)

        // Load data on screen creation
        launch {
            val session = currentSession.await() ?: return@launch
            val loaded = session.items.get(itemId).await()
            item.value = loaded
        }

        // UI automatically shows loading state until item is set
        reactiveScope {
            val i = item() ?: return@reactiveScope  // Labeled return
            h1 { content = i.name }
            text { content = i.description }
        }
    }
}

Key points:

  • Use Signal<T?>(null) instead of LateInitProperty<T>() for consistency
  • Use .get(id).await() to fetch from cache (waits for data if not cached)
  • Always check for null in reactiveScope with labeled return@reactiveScope
  • return@launch for early exit from launch blocks
  • .await() comes from com.lightningkite.reactive.context.*

Modifying Data Pattern

Use bracket notation cache[id].modify() for modifications:

// Modify a single field
session.users[userId].modify(modification {
    it.name assign "New Name"
})

// Modify multiple fields
session.reservations[reservationId].modify(modification {
    it.checkedInAt assign kotlin.time.Clock.System.now()
    it.beforeImages assign imageSet
})

// Modify based on current value
session.counters[counterId].modify(modification {
    it.count.increment(1)
})

Common modification operators:

  • assign - Set field to value
  • increment / decrement - Math operations
  • ListAppend / ListRemove - List operations
  • SetAppend / SetRemove - Set operations

ReactiveScope Labeled Returns

Always use labeled returns in reactiveScope blocks when handling nullable values:

// ✅ CORRECT - Labeled return
reactiveScope {
    val item = mySignal() ?: return@reactiveScope
    // Safe to use item here
    text { content = item.name }
}

// ✅ CORRECT - Labeled return in launch
launch {
    val session = currentSession.await() ?: return@launch
    // Safe to use session here
}

// ❌ WRONG - Bare return causes compilation error
reactiveScope {
    val item = mySignal() ?: return  // Error: "return is prohibited here"
}

Why labeled returns? reactiveScope is a lambda, not a function body, so you must specify which lambda you're returning from.

Common Patterns

Load and display list:

val items = Signal<List<Item>>(emptyList())

launch {
    val session = currentSession.await() ?: return@launch
    val result = session.items.query(Query(condition { it.active eq true })).await()
    items.value = result
}

forEach(items) { item ->
    card.text { ::content { item().name } }
}

Load nested data:

val reservation = Signal<Reservation?>(null)
val booth = Signal<Booth?>(null)

launch {
    val session = currentSession.await() ?: return@launch
    val res = session.reservations.get(reservationId).await()
    reservation.value = res

    // Load related data
    if (res != null) {
        booth.value = session.booths.get(res.booth).await()
    }
}

Modify on button click:

important.button {
    text { content = "Check In" }
    action = Action("Check In") {
        val session = currentSession.await() ?: return@Action

        session.reservations[reservationId].modify(modification {
            it.checkedInAt assign kotlin.time.Clock.System.now()
        })

        pageNavigator.navigate(NextScreen(reservationId))
    }
}

Troubleshooting

"Unresolved reference: await" → Missing import com.lightningkite.reactive.context.*

"Return is prohibited here" → Use labeled return: return@reactiveScope or return@launch

"Unresolved reference: name" (in modification block) → Don't import field paths at top level - they're auto-provided in modification {}

"Type mismatch: required Modification<T>, found Unit" → You're in a modification {} block - use assign not =

// ❌ Wrong
modification { it.name = "value" }

// ✅ Correct
modification { it.name assign "value" }

Server-Side Search Pattern

⚠️ CRITICAL: Always use server-side filtering for search, NOT client-side .filter()

When implementing search functionality with ModelCache, use server-side Query conditions with debouncing:

@Routable("members")
object MembersScreen : Page {
    override val title: Reactive<String> = Constant("Members")

    // URL-persisted search state (creates ?searchQuery= parameter)
    @QueryParameter
    val searchQuery = Signal("")

    // Debounce to prevent excessive API calls while typing
    val searchQueryDebounced = searchQuery.debounce(500)

    // Server-side filtered query
    val searchedUsersMeta = remember {
        val q = searchQueryDebounced()
        currentSessionNotNull().users.list(
            Query(condition {
                if(q.isEmpty()) Condition.Always
                else Condition.And(q.split(' ').map { p ->
                    it.name.contains(p, ignoreCase = true)
                })
            })
        )
    }
    val searchedUsers = remember { searchedUsersMeta()() }

    override fun ViewWriter.render(): Unit = run {
        col {
            // Search input
            field("Search") {
                textInput {
                    content bind searchQuery
                }
            }

            expanding.frame {
                // Empty state
                centered.shownWhen { searchedUsers().isEmpty() }.text {
                    content = "No matching users found."
                }

                // Efficient list rendering
                recyclerView {
                    children(searchedUsers, id = { it._id }) { user ->
                        card.row {
                            text { ::content { user().name } }
                            subtext { ::content { user().email } }
                        }
                    }
                }
            }
        }
    }
}

Key Components:

  1. @QueryParameter - URL-persisted state for shareable searches

    @QueryParameter
    val searchQuery = Signal("")
    
  2. Debouncing - Wait for user to stop typing (500ms standard)

    val searchQueryDebounced = searchQuery.debounce(500)
    
  3. Server-side filtering - Use Query conditions, NOT .filter()

    // ✅ CORRECT: Server-side filtering
    currentSessionNotNull().users.list(
        Query(condition {
            if(q.isEmpty()) Condition.Always
            else it.name.contains(q, ignoreCase = true)
        })
    )
    
    // ❌ WRONG: Client-side filtering (loads all data, slow, doesn't scale)
    val allUsers = remember { currentSessionNotNull().users.list(Query()) }
    val filtered = remember { allUsers()().filter { it.name.contains(searchQuery()) } }
    
  4. Split-word search - Search for each word separately

    Condition.And(query.split(' ').map { p ->
        it.name.contains(p, ignoreCase = true)
    })
    
  5. recyclerView + children() - Efficient rendering for large lists

    recyclerView {
        children(searchedUsers, id = { it._id }) { user ->
            // Renders each user
        }
    }
    
  6. Empty state - Show message when no results

    centered.shownWhen { items().isEmpty() }.text {
        content = "No matching items found."
    }
    

Required imports:

import com.lightningkite.kiteui.QueryParameter
import com.lightningkite.kiteui.views.l2.children
import com.lightningkite.kiteui.views.l2.field
import com.lightningkite.reactive.extensions.debounce
import com.lightningkite.services.database.Condition
import com.lightningkite.services.database.Query
import com.lightningkite.services.database.condition
import com.lightningkite.services.database.contains
import com.lightningkite.services.database.inside

Why server-side filtering?

  • Performance: Only loads matching records, not entire dataset
  • Scalability: Works with thousands/millions of records
  • Network efficiency: Minimal data transfer
  • Database optimization: Uses database indexes for fast searches

Why NOT client-side .filter()?

  • Loads ALL data into memory (slow, wasteful)
  • Doesn't scale beyond a few hundred records
  • No database index optimization
  • Increases network transfer and battery drain

Loading States

val data = LateInitProperty<MyData>()

col {
    // Automatically shows loading indicator while data is unset
    text { ::content { data().displayName } }
}

// Later, set the data
launch {
    data.value = fetchData()
}

Actions with Loading

Actions work on both buttons and text fields (runs on Enter key):

// Button action
button {
    text("Load Data")
    action = Action("Load", Icon.download) {
        // Button automatically shows loading state
        delay(2000)
        val result = fetchData()
        toast("Data loaded successfully!")  // User feedback
    }
}

// Text field action (runs on Enter key)
textInput {
    hint = "Search"
    content bind searchQuery
    action = Action("Search", Icon.search) {
        val results = performSearch(searchQuery())
        toast("Found ${results.size} results")
    }
}

// Action with API call and toast feedback
button {
    row {
        icon { source = AppIcons.lockOpen }
        text { content = "Unlock Door" }
    }
    action = Action("Unlock Door") {
        val session = currentSession.await() ?: return@Action
        session.api.reservation.unlockDoor(reservationId, doorId, "")
        toast("Door unlocked!")  // Immediate user feedback
    }
}

Key points:

  • Action blocks are suspend functions - can call async APIs directly
  • Automatic loading state on buttons while action runs
  • Use toast() for immediate user feedback after actions
  • Text field actions run when user presses Enter
  • Return early with return@Action if preconditions aren't met

Conditional Rendering

val showAdvanced = Signal(false)

col {
    checkbox {
        checked bind showAdvanced
    }

    reactiveScope {
        if (showAdvanced()) {
            card.col {
                h3("Advanced Options")
                // ... advanced controls
            }
        }
    }
}

Custom Components (Reusable Widgets)

Create reusable components as extension functions on ViewWriter:

fun ViewWriter.userCard(user: User) = card.row {
    gap = 1.rem
    image {
        source = user.avatarUrl
        sizeConstraints(width = 3.rem, height = 3.rem)
    }
    col {
        text(user.name)
        subtext(user.email)
    }
}

// Usage
col {
    forEach(users) { user ->
        userCard(user)
    }
}

Reusable Components with Bottom Sheets

For components used across multiple screens, create dedicated files:

// UnlockDoorsSheet.kt
package com.example.screens

import com.lightningkite.kiteui.views.ViewWriter
import com.lightningkite.kiteui.views.direct.*

/**
 * Reusable unlock doors bottom sheet content.
 */
fun ViewWriter.UnlockDoorsSheetContent() {
    col {
        h3 { content = "Unlock Doors" }
        subtext { content = "Select which doors to unlock:" }

        button {
            row {
                icon { source = AppIcons.lockOpen }
                text { content = "Main Entry Door" }
            }
            onClick { /* TODO: Unlock main door */ }
        }
        button {
            row {
                icon { source = AppIcons.lockOpen }
                text { content = "Equipment Room" }
            }
            onClick { /* TODO: Unlock equipment room */ }
        }
    }
}

/**
 * Reusable button that opens the unlock doors bottom sheet.
 */
fun ViewWriter.UnlockDoorsButton() {
    button {
        row {
            icon { source = AppIcons.lockOpen }
            text { content = "Unlock Doors" }
        }
        onClick {
            openBottomSheet {
                UnlockDoorsSheetContent()
            }
        }
    }
}

Usage across screens:

// In any screen
col {
    // ... other content ...
    UnlockDoorsButton()  // Just call the function
}

Key Points:

  • Extension functions on ViewWriter are automatically available in DSL context
  • Functions in the same package are accessible without imports
  • Separate content from trigger (e.g., SheetContent vs Button) for flexibility

Component that Loads its Own Data

fun ViewWriter.userDetails(userId: String) = col {
    val userData = LateInitProperty<UserData>()

    // Load data when component is created
    launch {
        userData.value = api.fetchUser(userId)
    }

    // UI automatically shows loading state
    text { ::content { "Name: ${userData().name}" } }
    text { ::content { "Email: ${userData().email}" } }
}

Units and Dimensions

// Rem units (relative to root font size) - preferred for responsive design
1.rem
2.5.rem

// Pixels (absolute)
100.px
50.px

// DP (density-independent pixels, Android concept)
16.dp

// Percentages
50.percent

Reactive Lens Extensions

KiteUI includes powerful reactive lens extensions for common transformations. These provide bidirectional bindings between different types.

Value Comparison - .equalTo()

Perfect for radio buttons and conditional checks:

val selected = Signal(1)

// Radio button binding
radioButton { checked bind selected.equalTo(1) }
radioButton { checked bind selected.equalTo(2) }
radioButton { checked bind selected.equalTo(3) }

Collection Membership - .contains()

Toggle items in sets or lists:

val tags = Signal(setOf<String>())
checkbox { checked bind tags.contains("important") }

val items = Signal(listOf<String>())
checkbox { checked bind items.contains("featured") }

Null Handling

Convert nullable signals to non-null with defaults:

// Nullable to non-null with default value
val name = Signal<String?>(null)
textInput { content bind name.notNull("Default") }

// String? to String (null becomes blank)
val description = Signal<String?>(null)
textInput { content bind description.nullToBlank() }

String to Number Conversions

Bind number inputs directly to string signals:

val ageStr = Signal("")

// Decimal conversions
numberInput { content bind ageStr.asInt() }
numberInput { content bind ageStr.asDouble() }
numberInput { content bind ageStr.asFloat() }
numberInput { content bind ageStr.asLong() }

// Hexadecimal conversions
val hexStr = Signal("")
numberInput { content bind hexStr.asIntHex() }
numberInput { content bind hexStr.asUIntHex() }

How Lens Extensions Work

These extensions use the reactive lens pattern to create bidirectional transformations:

// Example: .equalTo() implementation
infix fun <T> MutableReactive<T>.equalTo(value: T): MutableReactive<Boolean> = lens(
    get = { it == value },                    // Read: return true when equal
    modify = { o, it -> if (it) value else o } // Write: set value when true
)

This pattern enables:

  • Type-safe transformations at compile time
  • Bidirectional data flow between different types
  • Automatic updates when either side changes

Best Practices

Component Design

  1. Components should take minimal parameters unique per usage
  2. Components should load their own data (makes them easy to debug)
  3. Components should be used more than once; single-use components should be inlined
  4. Keep view hierarchy shallow to minimize theme recalculations

Modifier Order

Position > Visibility > Scroll > Theme

Example:

expanding.scrolling.card.col {
    // Position first (expanding)
    // Then scroll (scrolling)
    // Then theme (card)
}

State Management

  1. Use Signal for mutable state
  2. Use shared for computed values
  3. Use LateInitProperty for async-loaded data
  4. Prefer reactive functions (::content { }) over manual updates

Theming

  1. Use semantic themes (important, danger) over direct colors
  2. Apply themes to containers, not individual elements
  3. Let themes cascade from parent to children
  4. Use card for explicit backgrounds when needed

Navigation

  1. Use @Routable annotations for all pages
  2. Make deep linking easy with URL parameters
  3. Use typed navigation (navigate(MyPage)) over string URLs

Performance

  1. Use forEach for dynamic lists instead of manual child management
  2. Use Recycler2 (or recyclerView + children()) for long lists that need virtualization
  3. Use server-side filtering for search - NEVER use client-side .filter() on ModelCache results
  4. Keep view hierarchy shallow
  5. Batch child operations when possible
  6. Use shared to avoid redundant calculations
  7. Debounce user input (.debounce(500)) before triggering expensive operations

Advanced UI Patterns

Keyboard Shortcuts

Add keyboard shortcuts to actions for power users:

// Global shortcut
onKeyCode(keyCode { shortcut + it.letter('n') }) {
    pageNavigator.navigate(NewItemPage())
}

// Button with shortcut
button {
    text("Save")
    action = saveAction
    onKeyCode(keyCode { shortcut + it.letter('s') }) {
        saveAction.startAction(this@button)
    }
}

// Menu item with shortcut hint
menuButton {
    icon(Icon.notifications, "Notifications")
    onKeyCode(keyCode { shortcut + it.letter('n') }) {
        launch { handleNotifications() }
    }
    opensMenu { /* ... */ }
}

Keyboard modifier combinations:

  • shortcut - Cmd on Mac, Ctrl on Windows/Linux
  • shift - Shift key
  • alt - Alt/Option key
  • ctrl - Control key (even on Mac)

Menu Buttons with opensMenu

Create dropdown menus with complex content:

menuButton {
    icon(Icon.moreVert, "More options")
    requireClick = true  // Don't open on hover, only on click
    opensMenu {
        col {
            button {
                text("Edit")
                onClick { edit() }
            }
            button {
                text("Delete")
                onClick { delete() }
            }
            separator()
            row {
                checkbox { checked bind includeArchived }
                text("Include Archived")
            }
        }
    }
}

// Menu with dynamic theme
menuButton {
    dynamicTheme {
        if (hasUnread()) SelectedSemantic
        else null
    }
    icon(Icon.notification, "Notifications")
    opensMenu {
        sizeConstraints(width = 30.rem, height = 40.rem).col {
            // Complex menu content with recyclerView, etc.
        }
    }
}

Dynamic Theme Switching

Apply themes dynamically based on state:

// Highlight current selection
link {
    dynamicTheme {
        if (project()._id == currentProjectId()) SelectedSemantic
        else if (project()._id in myProjects()) null
        else NotRelevantSemantic
    }
    text { ::content { project().name } }
    to = { ProjectPage(project()._id) }
}

// Active timer indicator
menuButton {
    dynamicTheme {
        if (timerActive()) SelectedSemantic
        else null
    }
    icon(Icon.timer, "Timers")
    opensMenu { /* ... */ }
}

Common semantics for dynamic theming:

  • SelectedSemantic - Highlight active/selected items
  • NotRelevantSemantic - Dim irrelevant items
  • null - Use default theme

Drop Targets for Drag and Drop

Implement drag and drop for reordering or moving items:

card.link {
    dropTargetDelegate = object : DropTargetDelegate {
        override fun over(event: DragEvent): Boolean = true

        override fun drop(event: DragEvent): Boolean {
            val taskIds = DragConstants.decode(event.data) ?: return false
            confirmDanger("Move Tasks", "Move to this project?") {
                taskIds.forEach { taskId ->
                    launch {
                        session().task[taskId].modify(modification {
                            it.project assign targetProject._id
                        })
                    }
                }
            }
            return true
        }
    }
    // ... link content ...
}

Responsive Dual-Pane Navigation

Show multiple pages side-by-side on wide screens:

fun ViewWriter.sidewisePageNavigator(navigator: PageNavigator) = row {
    val pages = remember {
        val newPages = generateSequence(navigator.currentPage()) {
            (it as? PagePlus)?.parentPage
        }.toMutableList()
        newPages.reverse()

        // Limit to pages that fit on screen
        while (newPages.sumOf { spaceByPageType(it::class) } > windowWidth() && newPages.size > 1) {
            newPages.removeAt(0)
        }
        newPages.takeLast(2)
    }

    forEachAnimated(pages, preHidingModifiers = { weight(spaceByPageType(it::class).toFloat()) }) {
        it.run { render() }
    }
}

// Check if using panes
val usingPanes = remember {
    activePagesAndWeights()?.invoke().let { it != null && it.size > 1 }
}

Permission-Based UI Visibility

Show/hide UI elements based on user permissions:

shownWhen { session().myRole() != UserRole.Client }.button {
    text("Add Project")
    onClick { pageNavigator.navigate(NewProjectPage()) }
}

// Conditional action availability
link {
    ::to {
        if (session().hasPermission(Permission.Edit)) {
            { EditPage(item()._id) }
        } else null
    }
    text { ::content { item().name } }
}

Common Patterns

Login Screen

@Routable("login")
object LoginPage : Page {
    override fun ViewWriter.render(): ViewModifiable = run {
        val email = Signal("")
        val password = Signal("")

        frame {
            // Background image
            image {
                source = Resources.loginBackground
                scaleType = ImageScaleType.Crop
                opacity = 0.5
            }

            // Login form
            padded.scrolling.col {
                expanding.space()
                centered.sizeConstraints(maxWidth = 30.rem).card.col {
                    h1("Welcome")

                    field("Email") {
                        fieldTheme.textInput {
                            hint = "your@email.com"
                            keyboardHints = KeyboardHints.email
                            content bind email
                        }
                    }

                    field("Password") {
                        fieldTheme.textInput {
                            hint = "Password"
                            keyboardHints = KeyboardHints.password
                            content bind password
                            action = Action("Sign In", Icon.login) {
                                signIn(email(), password())
                            }
                        }
                    }

                    important.button {
                        text("Sign In")
                        onClick {
                            signIn(email(), password())
                        }
                    }
                }
                expanding.space()
            }
        }
    }

    private suspend fun ViewWriter.signIn(email: String, password: String) {
        // Login logic
        pageNavigator.navigate(HomePage)
    }
}

List Page with Search

@Routable("users")
object UsersPage : Page {
    override fun ViewWriter.render(): ViewModifiable = run {
        val searchQuery = Signal("")
        val users = Signal(listOf<User>())
        val filteredUsers = remember {
            val query = searchQuery().lowercase()
            users().filter { it.name.lowercase().contains(query) }
        }

        launch {
            users.value = api.fetchUsers()
        }

        col {
            card.row {
                expanding.textInput {
                    hint = "Search users"
                    content bind searchQuery
                }
            }

            scrolling.col {
                forEach(filteredUsers) { user ->
                    userCard(user)
                }
            }
        }
    }
}

Detail Page with Loading

@Routable("product/{id}")
class ProductPage(val id: String) : Page {
    override fun ViewWriter.render(): ViewModifiable = run {
        val product = LateInitProperty<Product>()

        launch {
            product.value = api.fetchProduct(id)
        }

        scrolling.col {
            // Automatically shows loading while product is unset
            image {
                ::source { product().imageUrl }
                scaleType = ImageScaleType.Crop
            }

            padded.col {
                h1 { ::content { product().name } }
                text { ::content { product().description } }
                text { ::content { "$${product().price}" } }

                important.button {
                    text("Add to Cart")
                    onClick {
                        addToCart(product())
                    }
                }
            }
        }
    }
}

Common Issues and Solutions

Issue: Route conflicts - Multiple @Routable with same path

Symptoms:

  • Navigating to a route shows the wrong screen
  • Old placeholder screens show instead of new implementations
  • Routes work inconsistently

Root Cause: Multiple files have @Routable annotations with the same path. KiteUI's route generator picks one arbitrarily, often the wrong one.

Solution: Delete the duplicate routes and update references.

Example diagnosis:

# Find duplicate routes
grep -r "@Routable(\"admin/organizations\")" apps/src

# Output shows duplicates:
apps/src/.../AdminOrgListScreen.kt:@Routable("admin/organizations")
apps/src/.../OrganizationManagementScreen.kt:@Routable("admin/organizations")

Fix:

  1. Delete the old/placeholder screen file
  2. Update all references to the deleted screen
  3. Rebuild frontend (routes are generated at compile time)
  4. Verify the correct screen loads
// Before (two files with same route)
// AdminOrgListScreen.kt
@Routable("admin/organizations")
object AdminOrgListScreen : Page { /* placeholder */ }

// OrganizationManagementScreen.kt
@Routable("admin/organizations")
object OrganizationManagementScreen : Page { /* real implementation */ }

// After (delete AdminOrgListScreen.kt, update references)
// ProfileScreen.kt
import com.example.screens.admin.OrganizationManagementScreen
card.link { to = { OrganizationManagementScreen } }

Prevention: Before creating a new screen, search for existing routes:

grep -r "@Routable(\"your/route/here\")" apps/src

Issue: Theme not applying

Solution: Ensure the view has been added to parent and postSetup() has been called. Themes cascade from parent.

Issue: State not updating UI

Solution: Use reactive scope with ::content { } or reactiveScope { } to make UI respond to state changes.

Issue: Memory leaks

Solution: Enable leak detection in development: RViewHelper.leakDetection = true. Check for strong references in closures and verify shutdown() is called.

Issue: Views not appearing

Solution: Check that parent view is actually visible and has size. Use sizeConstraints if needed.

Issue: Search is slow or loads all data

Symptoms:

  • Search input takes several seconds to respond
  • Browser/app freezes while typing
  • Network tab shows large data transfers
  • Search only works after loading completes

Root Cause: Using client-side .filter() on ModelCache results instead of server-side Query filtering.

❌ WRONG - Client-side filtering:

val allUsers = remember {
    currentSessionNotNull().users.list(Query())  // Loads ALL users
}
val filtered = remember {
    allUsers()().filter { it.name.contains(searchQuery()) }  // Filters in browser
}

✅ CORRECT - Server-side filtering:

@QueryParameter
val searchQuery = Signal("")

val searchQueryDebounced = searchQuery.debounce(500)

val searchedUsers = remember {
    val q = searchQueryDebounced()
    currentSessionNotNull().users.list(
        Query(condition {  // Server filters before sending
            if(q.isEmpty()) Condition.Always
            else it.name.contains(q, ignoreCase = true)
        })
    )
}

Why this matters:

  • Client-side: Loads 10,000 users → filters in browser → slow, wasteful
  • Server-side: Server filters → sends only 5 matching users → fast, efficient

See also: "Server-Side Search Pattern" section for complete implementation guide.

Testing and Deployment Setup

Critical Frontend Configuration

When setting up a KiteUI project for testing and deployment, several frontend-specific configurations must be correct for the application to work.

MJS Module Reference Must Match Project Name

⚠️ CRITICAL PITFALL: The generated JavaScript module name is based on the project name in settings.gradle.kts, not the template name. If index.html references the wrong module, you'll get a blank page.

Problem Symptom:

  • Browser shows blank page
  • Console error: "Failed to load url /ls-kiteui-starter-apps.mjs (404 Not Found)"
  • Vite dev server running without errors
  • No compilation errors

Root Cause: The Kotlin/JS Gradle plugin generates the JavaScript module with a filename based on rootProject.name:

// settings.gradle.kts
rootProject.name = "claude-coordinator"  // ← This determines the MJS filename

The generated module will be named: ${rootProject.name}-apps.mjs

Solution: Update index.html to reference the correct module name:

<!-- apps/src/jsMain/resources/index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
    <!-- ... -->
</head>
<body>
    <!-- ❌ WRONG - references template name, not project name -->
    <script src="/ls-kiteui-starter-apps.mjs" type="module"></script>

    <!-- ✅ CORRECT - matches rootProject.name from settings.gradle.kts -->
    <script src="/claude-coordinator-apps.mjs" type="module"></script>
</body>
</html>

How to diagnose:

  1. Check browser console for "Failed to load url" errors
  2. Look at Vite network tab to see which MJS file is being requested
  3. Compare with rootProject.name in settings.gradle.kts
  4. Update index.html to match project name

Prevention: When creating a new project from a template, immediately update index.html after changing the project name in settings.gradle.kts.

Vite Dev Server Port Configuration

For multi-project development, use isolated ports to avoid conflicts:

// apps/vite.config.mjs
export default {
  server: {
    host: true,
    port: 8942,  // Frontend port (isolated from other projects)
    allowedHosts: ["localhost:8942", "your-domain.com"],
    proxy: {
      '/api': {
        target: 'http://localhost:8082',  // Backend port
        rewrite: (path) => path.replace(/^\/api/, ''),
        ws: true,  // Enable WebSocket proxying
      }
    }
  }
}

Key points:

  • Choose unique port numbers for each project (e.g., increment by 2: 8940, 8942, 8944)
  • Update allowedHosts to include all domains you'll use
  • Proxy /api requests to backend server
  • Enable WebSocket support with ws: true

SDK Regeneration After Backend Changes

⚠️ CRITICAL: After changing server endpoints, you MUST regenerate the SDK before the frontend can use new endpoints.

# Regenerate SDK from server definitions
./gradlew :server:generateSdk

# Generated SDK appears in:
# apps/src/commonMain/kotlin/<package>/sdk/

Common errors from missing SDK regeneration:

  • Unresolved reference: respondToInput - Method doesn't exist in generated SDK
  • Type mismatch errors when calling API methods
  • Compilation errors in frontend after backend changes

Workflow:

  1. Add/modify server endpoint in server/src/main/kotlin/.../SomeEndpoints.kt
  2. Add request/response data classes to shared/src/commonMain/kotlin/.../models.kt
  3. Regenerate SDK: ./gradlew :server:generateSdk
  4. Implement frontend using new SDK methods
  5. Both backend and frontend should now compile successfully

Browser Testing with Chrome MCP

For end-to-end testing, use Claude's Chrome MCP tools for direct browser automation:

Setup workflow:

# 1. Start backend and wait for it to be ready
./testing/start-backend.sh
while ! curl -s http://localhost:8082 > /dev/null; do sleep 1; done

# 2. Capture admin token from backend logs (if debug mode enabled)
TOKEN=$(grep "Admin token:" server.log | cut -d"'" -f2)
echo "$TOKEN" > testing/.admin-token

# 3. Start frontend dev server
./testing/start-frontend.sh

# 4. Ready for browser testing at http://localhost:8942

Testing workflow with Chrome MCP:

  1. Get tab context: mcp__claude-in-chrome__tabs_context_mcp(createIfEmpty=true)
  2. Navigate to frontend: mcp__claude-in-chrome__navigate(tabId=X, url='http://localhost:8942')
  3. Take screenshot to verify page loaded: mcp__claude-in-chrome__computer(tabId=X, action='screenshot')
  4. Inject auth token if needed: mcp__claude-in-chrome__javascript_tool(tabId=X, text='localStorage.setItem(...)')
  5. Interact with page using find/click/form_input tools
  6. Verify UI state with screenshots

Benefits over Playwright:

  • Uses actual Chrome browser (same experience user sees)
  • No separate process needed
  • Direct visual feedback in browser window
  • Can interact with any tab in MCP group

Common Frontend Issues

Blank page with no errors:

  1. Check browser console for module load errors
  2. Verify MJS filename in index.html matches rootProject.name
  3. Ensure Vite dev server is running (./gradlew :apps:jsBrowserDevelopmentRun)
  4. Check that backend is accessible (proxy configuration)

Compilation errors after backend changes:

  1. Regenerate SDK: ./gradlew :server:generateSdk
  2. Check that new data classes are in shared/ module (visible to both server and frontend)
  3. Ensure import statements reference generated SDK paths

WebSocket connection issues:

  1. Verify ws: true in Vite proxy config
  2. Check backend WebSocket endpoint is enabled
  3. Ensure CORS settings allow WebSocket connections
  4. Test WebSocket URL directly in browser console

Hot reload not working:

  1. Restart Vite dev server: Ctrl+C then ./gradlew :apps:jsBrowserDevelopmentRun
  2. Check file watchers aren't at system limit
  3. Ensure files are in correct source sets (commonMain, jsMain)

Testing Infrastructure Scripts

Create reusable scripts in testing/ directory:

testing/start-backend.sh:

#!/bin/bash
cd "$(dirname "$0")/.."
./gradlew :server:run --args="serve settings=testing/settings.testing.json" &
echo $! > testing/.backend-pid

testing/start-frontend.sh:

#!/bin/bash
cd "$(dirname "$0")/.."
./gradlew :apps:jsBrowserDevelopmentRun &
echo $! > testing/.frontend-pid

testing/stop-all.sh:

#!/bin/bash
if [ -f testing/.backend-pid ]; then
    kill $(cat testing/.backend-pid) 2>/dev/null
    rm testing/.backend-pid
fi
if [ -f testing/.frontend-pid ]; then
    kill $(cat testing/.frontend-pid) 2>/dev/null
    rm testing/.frontend-pid
fi

testing/prepare-browser-test.sh:

#!/bin/bash
./testing/start-backend.sh
while ! curl -s http://localhost:8082 > /dev/null; do sleep 1; done
./testing/start-frontend.sh
echo "Ready for browser testing at http://localhost:8942"

Port Configuration:

  • Document chosen ports in testing/README.md
  • Update all scripts to use consistent port numbers
  • Keep ports isolated from other projects (increment by 2)

Resources

Key Takeaways

  1. KiteUI is web-first - Small bundles, fast performance, native HTML elements
  2. Use semantic theming - Style by meaning, not color
  3. Embrace reactivity - Use Signals and reactive scopes for dynamic UIs
  4. URL-based navigation - Deep linking is first-class
  5. Native views - Platform components, not canvas rendering
  6. Beautiful by default - Good-looking UIs without manual CSS

Error Handling and Form Validation

KiteUI includes a powerful automatic error handling system that catches exceptions from Actions and displays them to users with minimal boilerplate. Understanding this system is crucial for building robust forms and interactive features.

Core Concept: Actions and Automatic Error Handling

Actions are KiteUI's way of handling user interactions (like button clicks) with built-in loading states and error handling. When an exception occurs inside an Action, it's automatically caught and displayed to the user.

button {
    text("Save")
    action = Action("Save") {
        // Any exception thrown here is automatically caught
        val result = api.saveData(userInput())
        toast("Saved successfully!")
    }
}

What happens when an error occurs:

  1. The Action catches the exception
  2. Error propagates up the view hierarchy through ExceptionHandler chain
  3. ExceptionToMessage converters transform the exception into user-friendly text
  4. Error is displayed (dialog by default, or custom handler)
  5. Working/loading state automatically clears

How Error Interceptors Work

Error handling in KiteUI uses a hierarchical chain of handlers similar to exception handling in many frameworks. There are two types of handlers you can add to any view:

1. ExceptionHandler - Controls How Errors Are Displayed

ExceptionHandler determines what happens when an error occurs (show dialog, toast, inline error, etc.):

col {
    // Add custom error handler to this view and all children
    this += object : ExceptionHandler {
        override val priority: Float = 10f  // Higher priority = checked first

        override fun handle(view: RView, working: Boolean, exception: Exception): (() -> Unit)? {
            // Return null to pass to next handler
            // Return a cleanup function if you handled it

            if (exception is ValidationException) {
                // Show inline error instead of dialog
                toast(exception.message ?: "Validation failed")
                return {} // Return cleanup function (empty in this case)
            }

            return null // Pass to next handler
        }
    }

    // Children inherit this handler
    button {
        action = Action("Submit") {
            throw ValidationException("Email is required")
        }
    }
}

2. ExceptionToMessage - Converts Exceptions to User-Friendly Text

ExceptionToMessage transforms technical exceptions into messages users can understand:

col {
    // Add custom message converter
    this += ExceptionToMessage<NetworkException>(priority = 5f) {
        ExceptionMessage(
            title = "Connection Error",
            body = "Could not connect to server. Check your internet connection.",
            actions = listOf(
                Action("Retry") { retryLastAction() }
            )
        )
    }

    button {
        action = Action("Load Data") {
            // If this throws NetworkException, the converter above handles it
            api.fetchData()
        }
    }
}

Built-in converters (from LsErrorHandlers.kt):

  • LsErrorException - Server errors from Lightning Server
    • 400 → "Incorrectly formed information was sent."
    • 401 → "You're not authenticated properly."
    • 403 → "You're not allowed to do this."
    • 500 → "Something's wrong with the server."

Adding Lightning Server error handling:

// In your app initialization
ExceptionToMessages.root.installLsError()

Local Validation vs Server Validation

Client-Side (Local) Validation - For UX:

  • Instant feedback before server call
  • Check formatting, required fields, basic rules
  • Prevent unnecessary network requests
  • User-friendly error messages

Server-Side Validation - For Security:

  • NEVER trust client input
  • Enforce business rules and permissions
  • Validate data integrity and constraints
  • Protect against malicious clients
// Example: Form with both local and server validation

val email = Signal("")
val password = Signal("")

col {
    field("Email") {
        textInput {
            hint = "your@email.com"
            content bind email
        }
    }

    field("Password") {
        textInput {
            hint = "Password"
            keyboardHints = KeyboardHints.password
            content bind password
        }
    }

    button {
        text("Sign Up")
        action = Action("Sign Up") {
            // LOCAL VALIDATION (UX - instant feedback)
            val emailValue = email().trim()
            val passwordValue = password()

            if (emailValue.isBlank()) {
                throw PlainTextException("Email is required", "Validation Error")
            }

            if (!emailValue.contains("@")) {
                throw PlainTextException("Please enter a valid email address", "Invalid Email")
            }

            if (passwordValue.length < 8) {
                throw PlainTextException("Password must be at least 8 characters", "Weak Password")
            }

            // SERVER VALIDATION (Security - never trust client)
            // Server will re-validate everything and enforce business rules
            val result = api.auth.signUp(emailValue, passwordValue)

            // Server might throw:
            // - 400: Email format invalid (server's stricter rules)
            // - 409: Email already exists
            // - 403: Registration closed
            // - 500: Database error

            pageNavigator.navigate(HomePage)
        }
    }
}

Displaying Errors: Field-Level vs Global

Global Errors (Toast/Dialog)

Default behavior - Errors show in a dialog:

button {
    action = Action("Delete") {
        api.deleteItem(itemId())
        // Error shows in dialog automatically
    }
}

Toast for non-critical errors:

col {
    // Custom handler that shows toasts instead of dialogs
    this += object : ExceptionHandler {
        override val priority: Float = 10f
        override fun handle(view: RView, working: Boolean, exception: Exception): (() -> Unit)? {
            if (exception is MinorException) {
                view.toast(exception.message ?: "An error occurred")
                return {}
            }
            return null  // Pass to default dialog handler
        }
    }
}

Field-Level Errors (Inline)

Show errors next to specific fields for better UX:

val email = Signal("")
val emailError = Signal<String?>(null)

col {
    field("Email") {
        textInput {
            hint = "your@email.com"
            content bind email
        }
    }

    // Error message below field
    reactiveScope {
        emailError()?.let { error ->
            danger.text { content = error }
        }
    }

    button {
        text("Submit")
        action = Action("Submit") {
            emailError.value = null  // Clear previous errors

            // Validate
            if (!email().contains("@")) {
                emailError.value = "Invalid email format"
                throw PlainTextException("Please fix the errors above", "Validation Failed")
            }

            try {
                api.submit(email())
                emailError.value = null
            } catch (e: LsErrorException) {
                if (e.status == 409) {
                    emailError.value = "This email is already registered"
                }
                throw e  // Re-throw for dialog
            }
        }
    }
}

Error Types: Automatic vs Manual Handling

Automatically Handled Errors

These errors are caught and displayed automatically when thrown from an Action:

// ✅ Automatic - Exception caught and shown in dialog
button {
    action = Action("Load") {
        throw Exception("Something went wrong")  // Automatically shown
    }
}

// ✅ Automatic - Server errors
button {
    action = Action("Save") {
        api.user.update(user())  // LsErrorException automatically converted to message
    }
}

// ✅ Automatic - Custom exceptions
button {
    action = Action("Process") {
        throw PlainTextException(
            "Unable to process payment",
            title = "Payment Failed",
            actions = listOf(Action("Retry") { retryPayment() })
        )
    }
}

Manually Handled Errors

Errors outside of Actions need explicit handling:

// ❌ NOT automatic - No Action wrapping this
launch {
    try {
        val data = api.fetchData()
    } catch (e: Exception) {
        // Must handle manually
        toast("Error: ${e.message}")
    }
}

// ✅ Manual handling with helper
launch {
    try {
        val data = api.fetchData()
    } catch (e: Exception) {
        // Convert to user-friendly message
        val message = exceptionToMessage(e)
        dialog { close ->
            card.col {
                h2(message?.title ?: "Error")
                text(message?.body ?: "An error occurred")
                button {
                    text("OK")
                    onClick { close() }
                }
            }
        }
    }
}

Common Form Validation Patterns

Pattern 1: Simple Validation with Inline Errors

val username = Signal("")
val usernameError = Signal<String?>(null)

field("Username") {
    textInput {
        content bind username
        // Clear error when user types
        reactive { content(); usernameError.value = null }
    }
}

reactiveScope {
    usernameError()?.let { error ->
        danger.subtext { content = error }
    }
}

button {
    action = Action("Create Account") {
        usernameError.value = null

        if (username().length < 3) {
            usernameError.value = "Username must be at least 3 characters"
            return@Action
        }

        api.createAccount(username())
    }
}

Pattern 2: Multi-Field Validation

data class FormErrors(
    val email: String? = null,
    val password: String? = null,
    val confirmPassword: String? = null
)

val errors = Signal(FormErrors())

fun validate(): Boolean {
    val newErrors = FormErrors(
        email = when {
            email().isBlank() -> "Email required"
            !email().contains("@") -> "Invalid email"
            else -> null
        },
        password = when {
            password().length < 8 -> "Password must be 8+ characters"
            else -> null
        },
        confirmPassword = when {
            confirmPassword() != password() -> "Passwords don't match"
            else -> null
        }
    )

    errors.value = newErrors
    return newErrors.run { email == null && password == null && confirmPassword == null }
}

col {
    field("Email") {
        textInput { content bind email }
    }
    reactiveScope {
        errors().email?.let { danger.subtext { content = it } }
    }

    field("Password") {
        textInput {
            keyboardHints = KeyboardHints.password
            content bind password
        }
    }
    reactiveScope {
        errors().password?.let { danger.subtext { content = it } }
    }

    field("Confirm Password") {
        textInput {
            keyboardHints = KeyboardHints.password
            content bind confirmPassword
        }
    }
    reactiveScope {
        errors().confirmPassword?.let { danger.subtext { content = it } }
    }

    button {
        text("Sign Up")
        action = Action("Sign Up") {
            if (!validate()) {
                throw PlainTextException("Please fix the errors above", "Validation Failed")
            }
            api.signUp(email(), password())
        }
    }
}

Pattern 3: Async Validation (Check Availability)

val username = Signal("")
val isCheckingAvailability = Signal(false)
val availabilityMessage = Signal<String?>(null)

textInput {
    content bind username
}

reactiveScope {
    val name = username()
    if (name.length >= 3) {
        isCheckingAvailability.value = true
        launch {
            delay(500)  // Debounce
            try {
                val available = api.checkUsernameAvailable(name)
                availabilityMessage.value = if (available) {
                    "✓ Username available"
                } else {
                    "✗ Username taken"
                }
            } catch (e: Exception) {
                availabilityMessage.value = null
            } finally {
                isCheckingAvailability.value = false
            }
        }
    } else {
        availabilityMessage.value = null
    }
}

reactiveScope {
    availabilityMessage()?.let { msg ->
        val isAvailable = msg.startsWith("✓")
        if (isAvailable) {
            subtext { content = msg }
        } else {
            danger.subtext { content = msg }
        }
    }
}

Pattern 4: Server-Driven Validation Errors

When the server returns field-specific errors:

data class ValidationErrors(
    val fieldErrors: Map<String, String> = emptyMap(),
    val globalError: String? = null
)

val errors = Signal(ValidationErrors())

col {
    // Add custom handler for validation errors
    this += ExceptionToMessage<LsErrorException> { exception ->
        // Server returns 400 with field errors in response
        if (exception.status == 400) {
            try {
                val errorData = Json.decodeFromString<ValidationErrors>(exception.error.message)
                errors.value = errorData

                ExceptionMessage(
                    title = "Validation Failed",
                    body = errorData.globalError ?: "Please fix the errors below"
                )
            } catch (e: Exception) {
                null  // Pass to default handler
            }
        } else null
    }

    field("Email") {
        textInput { content bind email }
    }
    reactiveScope {
        errors().fieldErrors["email"]?.let { error ->
            danger.subtext { content = error }
        }
    }

    field("Username") {
        textInput { content bind username }
    }
    reactiveScope {
        errors().fieldErrors["username"]?.let { error ->
            danger.subtext { content = error }
        }
    }

    button {
        text("Submit")
        action = Action("Submit") {
            errors.value = ValidationErrors()  // Clear previous errors
            api.submitForm(email(), username())
            // Server might throw LsErrorException with field errors
        }
    }
}

Best Practices

  1. Always validate locally before server calls - Better UX, less load
  2. Never skip server validation - Security is non-negotiable
  3. Clear errors when user starts typing - Feels more responsive
  4. Use Actions for all user interactions - Automatic error handling
  5. Provide specific error messages - "Email is required" not "Invalid input"
  6. Show errors near related fields - Easier for users to fix
  7. Add retry actions for transient errors - Network issues, timeouts
  8. Use PlainTextException for custom errors - Clean, user-friendly messages

Summary: The Error Handling Flow

User clicks button with Action
    ↓
Action executes (suspend function)
    ↓
Exception thrown (validation, network, etc.)
    ↓
StatusListener catches exception
    ↓
Walk up view hierarchy looking for ExceptionHandler
    ↓
If found: Use ExceptionToMessage to convert exception
    ↓
Display to user (dialog, toast, inline)
    ↓
Clear working/loading state

Key Insight: You rarely need try-catch in Actions. Just throw exceptions and let KiteUI handle them. Focus on clear error messages and good UX patterns.

Critical Documentation Development Bug (FIXED!)

Autogeneration Plugin Issue with String Literals

Problem (NOW FIXED): The autogeneration plugin was incorrectly parsing @Routable annotations inside string literals (including triple-quoted strings) as actual route declarations, causing broken autoroutes.kt generation.

Fix Applied: Modified gradle-plugin/src/main/kotlin/parsingHelpers.kt and generateRoutes.kt:

  1. Added isInsideStringLiteral() helper function that properly tracks:
    • Single-quoted strings: 'x'
    • Double-quoted strings: "text"
    • Triple-quoted strings: """text"""
    • Escape sequences in regular strings
  2. Modified route generation to skip any @Routable annotations found inside string literals

Files Modified:

  • gradle-plugin/src/main/kotlin/parsingHelpers.kt - Added string literal detection
  • gradle-plugin/src/main/kotlin/generateRoutes.kt - Added check before processing @Routable

Result: Documentation pages can now safely include code examples with @Routable annotations in strings without triggering false route generation. The example() function works correctly with complex code samples.

Verification:

# Test that annotations in strings are ignored:
grep "@Routable" DocumentationPage.kt
# Should only find the real annotation, not ones in code examples

# Generated routes should match real annotations only:
grep "HelloWorldPage\|TodoPage\|UserPage" autoroutes.kt
# Should return nothing if these are only in string examples
Skills Info
Original Name:kiteui-developmentAuthor:kf7mxe