Skip to main content

ui-app

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.

跳到安装

来源信息

仓库
amberframework/asset_pipeline
最近来源活动
2026年5月25日 15:23
检测到的 SKILL.md 语言
英语
星标
5
分支
0

安装方式

默认使用会先检查来源的 Prompt;你也可以切换为直接命令,或下载本地副本。

检查来源文件

决定是否安装前,请先阅读 SKILL.md,以及 SkillsMP 当前展示的配套文件。

正在显示 SKILL.md

SKILL.md
来源说明 · 只读预览
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 查看
这个 SKILL.md 很大,SkillsMP 这里只预览前一段内容。 在 GitHub 查看