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.

Aller à l'installation

Informations de source

Dépôt
amberframework/asset_pipeline
Dernière activité de la source
25 mai 2026 à 15:23
Langue détectée de SKILL.md
anglais
Étoiles
5
Forks
0

Options d'installation

Le prompt qui vérifie d'abord la source est sélectionné par défaut. Vous pouvez passer à une commande directe ou télécharger une copie locale.

Vérifiez les fichiers source

Lisez SKILL.md et les fichiers associés affichés par SkillsMP avant de décider de l'installer.

Affichage de SKILL.md

SKILL.md
Instructions source · Aperçu en lecture seule
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,
Voir sur GitHub
Ce SKILL.md est tres volumineux, SkillsMP affiche donc ici seulement la premiere section. Voir sur GitHub