- name
- ui-app
- description
- Build cross-platform apps with UI::App + UI::Screen + UI::Controller + UI::ActionDispatcher + UI::FormState — route declarations, native action dispatch, Amber web integration, and the static-site web target.
- user-invocable
- true
# Build apps with UI::App, Controllers, and the ActionDispatcher
You are wiring a cross-platform application on top of the asset_pipeline cross-platform UI system. `UI::View` composition (handled by the `build-ui` and `component-api` skills) gives you the visual tree; `UI::App` and its collaborators give you the app shell — routes, navigation, controllers, action dispatch, form state, session, flash, and host integration on macOS, iOS, and Amber web.
This skill is the operational reference. The narrative tutorial lives at `docs/initiative-cross-platform-ui/tutorial-ui-app.md` — read that first if you've never wired a `UI::App` before.
---
## 1. When To Use This Skill
| Goal | Skill |
|------|-------|
| Compose a `UI::View` tree (VStack / Button / TextField / Card / etc.) | `build-ui` |
| Look up the API surface of a single `UI::View` type | `component-api` |
| Declare routes, controllers, action dispatch, host bootstrap, Amber wiring | **`ui-app` (this skill)** |
Use `ui-app` when you are:
- Declaring a `UI::App` subclass with `screen :route_id, FooController`.
- Writing a `UI::Controller` subclass that handles an action (`:submit`, `:edit_row`, `:open_settings`).
- Wiring a native host (macOS or iOS) to drive screens through `UI::ActionDispatcher`.
- Wiring an Amber app to drive the same `UI::App` through `UI::AmberIntegration.routes_for`.
- Reading `ctx.form_state["email"]`, `ctx.action_params["todo_id"]`, `ctx.session`, or `ctx.flash` inside a controller.
- Reasoning about which target — native dispatcher, Amber full-server, or Voyager static-site — your screen runs in.
If you are styling a button, picking a `UI::Color` role, or composing a card layout, you want `build-ui` or `component-api`, not this skill.
---
## 2. Core Architecture
Phase 8 added a four-piece app model layered on top of the existing `UI::View` system. Plus `UI::FormState` for controlled inputs.
```
UI::App — declarative route registry (one subclass per app)
├── UI::Screen — pure render: build(ctx) -> UI::View
├── UI::Controller — native actions; returns UI::ActionResult
└── UI::ActionDispatcher — routes action refs → controllers → coordinator/host
UI::FormState — per-mount controlled-input store
UI::ScreenContext — abstract; Web + Native concrete subclasses
UI::ActionResult — Navigate | Pop | Rerender | ReplaceRoot | RenderInline
```
**Lifecycle (native, per-mount):**
1. Host calls `App.bootstrap!` once at startup.
2. Host constructs `state`, `NavigationCoordinator`, `Session`, `Flash`, then `UI::ActionDispatcher`.
3. Host calls `dispatcher.mount_screen(coord.current)` to allocate the FIRST FormState and bump the mount token.
4. Host publishes the dispatcher into its holder (e.g. Voyager uses sample-local `Voyager.dispatcher = dispatcher`).
5. Each subsequent action (Button tap, form submit) closures into `Voyager.dispatch(:action_name, action_params)`, which calls `dispatcher.dispatch(:action_name, params)`.
6. The dispatcher resolves the current route's controller, runs before_actions, calls `controller.dispatch_action`, and translates the returned `UI::ActionResult` into a coordinator op — mounting the target route's fresh FormState BEFORE the coordinator publishes the new tree.
**Source files:**
- `src/asset_pipeline/native_app.cr` — `UI::App`, `screen` macro, `bootstrap!`, `registration_for`.
- `src/asset_pipeline/native_controller.cr` — `UI::Controller`, `dispatch_action`, `before_action`, `navigate_to` / `pop_navigation` / `render_current_screen` / `replace_root` / `respond_with`.
- `src/asset_pipeline/action_dispatcher.cr` — `UI::ActionDispatcher#dispatch`, `mount_screen`, `translate_result`.
- `src/asset_pipeline/action_result.cr` — `UI::ActionResult` and its five subclasses.
- `src/asset_pipeline/native_context.cr` — `UI::ScreenContext::Native`, `UI::Session::InProcess`, `UI::Flash::InProcess`.
- `src/asset_pipeline/amber_integration.cr` — `UI::Screen`, `UI::ScreenContext` abstract + `Web` concrete, `UI::AmberIntegration.routes_for`.
- `src/ui/form_state.cr` — `UI::FormState`, `UI::FormState.current`, `UI::FormState.current_mount_token`.
---
## 3. Screens
A screen is a pure-render function. Subclass `UI::Screen` and implement `build(context : UI::ScreenContext) : UI::View`.
```crystal
class TasksScreen < UI::Screen
def build(context : UI::ScreenContext) : UI::View
root = UI::VStack.new(spacing: 16.0)
root << UI::Label.new("Tasks")
add_btn = UI::Button.new("Add task")
add_btn.on_tap = -> { TasksApp.dispatch(:new_task) }
root << add_btn.as(UI::View)
root.as(UI::View)
end
end
```
**Rules:**
- **No domain mutations in `build`.** Build is called on every render — it must be idempotent given the same `ctx`. App / domain state mutations belong in controllers (Rule 1).
- **View-local affordance closures ARE allowed.** A `title_field.on_change = ->(v : String) { save.disabled = v.empty? }` closure mutates only view-local visual state. This is correct and intended (Rule 2). Routing this through a dispatcher `Rerender` would allocate a fresh `FormState` and lose the in-progress typed value (Phase 8D.3a co-plan).
- **Screen instances are stateless.** Per-render data lives on `ScreenContext`; a new screen instance is constructed for every build call.
`UI::Screen` is the abstract base; both the native dispatcher path and the Amber path consume the same subclass. The same screen class can render on both targets when the build method reads only the abstract `ScreenContext` surface (`params`, `params_multi`, `flash_data`, `design_tokens`, `csrf_token`).
Voyager's `Voyager::TodosScreen#build` (in `samples/initiative-cross-platform-ui-voyager/screens/todos.cr`) is the canonical realistic example — device-aware sizing, list rendering, multiple action refs per row.
---
## 4. Controllers And Actions
A controller owns the actions for one screen. Subclass `UI::Controller` and override `dispatch_action(name, context)`.
```crystal
class TasksController < UI::Controller
before_action :require_signed_in
def dispatch_action(name : Symbol, context : UI::ScreenContext::Native) : UI::ActionResult
case name
when :new_task then new_task(context)
when :toggle_row then toggle_row(context)
else raise UI::Controller::UnknownActionError.new(
"TasksController has no action :#{name}")
end
end
def new_task(context : UI::ScreenContext::Native) : UI::ActionResult
navigate_to(:task_editor)
end
def toggle_row(context : UI::ScreenContext::Native) : UI::ActionResult
todo_id = context.action_params["todo_id"]
TasksApp.state.toggle(todo_id)
render_current_screen
end
private def require_signed_in(context : UI::ScreenContext::Native) : UI::ActionResult?
return nil if context.session["user_email"]?
replace_root(:sign_in)
end
end
```
**Reads available on `ctx : UI::ScreenContext::Native`:**
| Reader | Source | Use for |
|--------|--------|---------|
| `ctx.form_state["email"]?` | Renderer-wired typed input values for the CURRENT mount | Form submission inputs |
| `ctx.action_params["todo_id"]` | Per-tap payload from the button's closure | Row identity, action-scoped data |
| `ctx.session["user_email"]` | Per-app in-process Session (`UI::Session::InProcess`) | Auth, persistent flags |
| `ctx.flash["error"]` | Per-app in-process Flash (`UI::Flash::InProcess`) | One-shot messages |
| `ctx.design_tokens` | `UI::DesignTokens::Tokens` for the app | Inline color/spacing reads |
| `ctx.navigation` | The `NavigationCoordinator` (for depth-aware affordances) | Back button visibility |
**Returns:**
Every action returns a `UI::ActionResult`. The controller-side helpers (`navigate_to`, `pop_navigation`, `render_current_screen`, `replace_root`, `respond_with`) construct them on your behalf.
**`before_action`:**
`before_action :method_name` registers a callback that runs before every action method on that controller. The callback receives the context and returns `UI::ActionResult?` — return `nil` to continue, return a result to short-circuit (typically `replace_root(:sign_in)` for an unauth guard).
**Note on macros:** `before_action` is gap-safe — it emits a named class method per registration so the iOS class-init gap can't strand the callback list. See `src/asset_pipeline/native_controller.cr` for the mechanism.
---
## 5. Action References (Shipped API)
Views call `Voyager.dispatch(:action_name, action_params)` (or your app's equivalent helper) from callback closures. The dispatcher accepts either form:
- **Symbol** — runs the action on the **CURRENT route's** registered controller.
- **`Tuple(UI::Controller.class, Symbol)`** — runs the action on the **explicitly named** controller (cross-controller dispatch).
```crystal
# Current route's controller (typical case).
submit = UI::Button.new("Sign in")
submit.on_tap = -> { Voyager.dispatch(:submit) }
# Per-tap action_params (row identity, etc.).
edit_btn.on_tap = -> {
Voyager.dispatch(:edit_row, {"todo_id" => todo_id_str})
}
# Explicit cross-controller dispatch (rare; use sparingly).
sign_out_btn.on_tap = -> {
Voyager.dispatcher.try &.dispatch({SignInController, :sign_out})
}
```
**Important: NEVER write `Button(action: :submit)` or `UI::Button.new(action: :submit, params: ...)`.** An older Phase 8 design draft proposed that kwarg-style syntax, but **it was never shipped**. The shipped API uses callback closures (`button.on_tap = -> { ... }`) that call your app's dispatcher helper. Examples that show the kwarg form are stale and should be ignored.
**Wiring the dispatcher helper** (Voyager pattern):
```crystal
module Voyager
@@dispatcher : UI::ActionDispatcher? = nil
def self.dispatcher=(d : UI::ActionDispatcher?); @@dispatcher = d; end
def self.dispatcher; @@dispatcher; end
def self.dispatch(name : Symbol, action_params : Hash(String, String) = {} of String => String) : Nil
d = @@dispatcher
return nil if d.nil?
d.dispatch(name, action_params)
nil
end
end
```
This is sample-local. There is no generic `UI::App.dispatcher` slot in the framework — each host names its own holder. The framework owns the dispatcher object; the host owns publication.
**Cross-controller note (R11):** The tuple form (`{Controller, :action}`) is intentional cross-controller dispatch for a specific class of cases (e.g. a global sign-out button). It is NOT a command bus — do not use it to bypass route ownership of actions.
---
## 6. Action Results
A controller action returns one of five `UI::ActionResult` subtypes. The dispatcher's `translate_result` (`src/asset_pipeline/action_dispatcher.cr`) does the right thing for each.
| Subtype | Constructor helper | What it does |
|---------|--------------------|--------------|
| `Navigate` | `navigate_to(:route_id, params)` | Mount the new route's FormState; push onto the navigation stack. |
| `Pop` | `pop_navigation` | Mount the underlying route's FormState; pop the stack. No-op at root. |
| `Rerender` | `render_current_screen` | Re-mount the same route (fresh FormState, bumped token); republish. |
| `ReplaceRoot` | `replace_root(:route_id, params)` | Mount the new route's FormState; replace the entire stack. |
| `RenderInline` | `respond_with(view)` | Emit through `dispatcher.on_render_inline` callback (host-bound). No stack change. |
**`Rerender` vs view-local closures:** Use `Rerender` after a domain mutation that should propagate into the rebuilt view (e.g. toggle a todo). DO NOT use `Rerender` to flip a single button's disabled state in response to a TextField — that's view-local affordance (Rule 2) and belongs on the field's `on_change` closure. Routing it through the dispatcher rebuilds the entire screen and discards the typed input.
**`RenderInline`:** Use for sheets, popovers, and inline overlays that should NOT push onto the navigation stack. The host binds `dispatcher.on_render_inline` to its own presentation code (e.g. AppKit `presentAsSheet:`).
---
## 7. Native Host Wiring
The canonical native wiring sequence lives in Voyager's `HostBootstrap.build` (`samples/initiative-cross-platform-ui-voyager/host_bootstrap.cr`). Prefer calling it directly — manual hosts MUST follow the same order.
```crystal
module Voyager
module HostBootstrap
def self.build(initial_route_id : Symbol = :sign_in) : Result
VoyagerApp.bootstrap! # (1) re-run screen registrations (iOS gap recovery)
state = Voyager::State.new # (2) app/domain state
Voyager.state = state
coord = UI::NavigationCoordinator.new( # (3) navigation coordinator
UI::NavigationCoordinator::Route.new(initial_route_id)
)
session = UI::Session::InProcess.new # (4) session
flash = UI::Flash::InProcess.new # (5) flash
dispatcher = UI::ActionDispatcher.new( # (6) dispatcher binds them all
app: VoyagerApp,
navigation: coord,
session: session,
flash: flash,
GitHub에서 보기