inquirex-ui

Widget rendering hints for Inquirex flow definitions.

inquirex-ui enriches an Inquirex Definition with framework-agnostic rendering metadata — the widget DSL verb attaches a WidgetHint to each step node for each rendering target (:desktop, :mobile, :tty, …), describing the preferred UI control. Frontend adapters (inquirex-tty, inquirex-js, inquirex-rails) consume these hints to pick the right renderer. This gem produces enriched JSON — no HTML or JavaScript is generated here.

Installation

gem "inquirex-ui"

inquirex-ui depends only on inquirex (the core gem). Both have zero required runtime dependencies beyond Ruby itself.

Inquirex.define vs Inquirex::UI.define

The two entry points share the same DSL — the only difference is whether nodes carry widget hints:

Entry point Node class widget verb
inquirex Inquirex.define Inquirex::Node not available
inquirex-ui Inquirex::UI.define Inquirex::UI::Node available

Both return an Inquirex::Definition that works identically with Inquirex::Engine, to_json/from_json, MermaidExporter, etc. inquirex-ui is a strict superset — everything you can write in Inquirex.define works unchanged inside Inquirex::UI.define.

Use Inquirex.define when you only need the flow logic: server-side processing, pure Ruby scripts, tests that don't care about rendering.

Use Inquirex::UI.define when the definition will be consumed by a frontend adapter (inquirex-tty, inquirex-js, Rails views) that needs to know how to render each question.

Quick Start

require "inquirex/ui"

definition = Inquirex::UI.define id: "tax-intake", version: "1.0.0" do
  meta title: "Tax Preparation Intake"

  start :filing_status

  ask :filing_status do
    type :enum
    question "What is your filing status?"
    options single:             "Single",
            married_jointly:    "Married Filing Jointly",
            married_separately: "Married Filing Separately",
            head_of_household:  "Head of Household"
    widget target: :desktop, type: :radio_group, columns: 2
    widget target: :mobile,  type: :dropdown
    widget target: :tty,     type: :select
    transition to: :income
  end

  ask :income do
    type :currency
    question "Estimated annual income?"
    # No widget call — WidgetRegistry provides :currency_input by default
    transition to: :has_deductions
  end

  confirm :has_deductions do
    question "Any deductions to claim?"
    # WidgetRegistry default: :toggle (desktop) / :yes_no_buttons (mobile)
    transition to: :deductions, if_rule: equals(:has_deductions, true)
    transition to: :done
  end

  ask :deductions do
    type :multi_enum
    question "Select applicable deductions."
    options %w[Mortgage Charitable Medical]
    widget target: :desktop, type: :checkbox_group, layout: :vertical
    widget target: :tty,     type: :multi_select
    transition to: :done
  end

  say :done do
    text "Thank you for completing the intake."
  end
end

The definition object is a standard Inquirex::Definition — fully compatible with Inquirex::Engine, to_json/from_json, and Inquirex::Graph::MermaidExporter. Every step is an Inquirex::UI::Node (a subclass of Inquirex::Node) with widget hints attached.


The widget DSL Verb

widget is available inside any step block when using Inquirex::UI.define. Call it once per target context. The target: keyword defaults to :desktop, and the design is open-ended — any adapter may introduce its own targets (:watch, :tv, :embed, …).

ask :priority do
  type :enum
  question "How urgent is this?"
  options low: "Low", medium: "Medium", high: "High"

  widget target: :desktop, type: :radio_group, columns: 3
  widget target: :mobile,  type: :dropdown
  widget target: :tty,     type: :select

  transition to: :next_step
end

type: names the widget. All remaining keyword arguments become the hint's options hash and are passed through to the adapter unchanged.

target: can be any symbol. The three built-in targets are :desktop, :mobile, and :tty. Additional targets (:watch, :embed, …) are valid — adapters can define their own without any changes to this gem.

Widget DSL Method

Signature Purpose
widget(target: :desktop, type:, **opts) Set rendering hint for the given target

target: defaults to :desktop — you can omit it when only a single hint is needed:

widget type: :text_input, placeholder: "e.g. Alice"

When no widget call is made for a target, WidgetRegistry fills in the default for that data type. Adapters may also call #effective_widget_hint_for(target:) on any node to get the explicit hint or the registry fallback in one call.


Widget Registry (Auto-Defaults)

When no explicit widget call is made, WidgetRegistry provides a sensible default per data type and rendering context:

Data Type :desktop default :mobile default :tty default
:string text_input text_input text_input
:text textarea textarea multiline
:integer number_input number_input number_input
:decimal number_input number_input number_input
:currency currency_input currency_input number_input
:boolean toggle yes_no_buttons yes_no
:enum radio_group dropdown select
:multi_enum checkbox_group checkbox_group multi_select
:date date_picker date_picker text_input
:email email_input email_input text_input
:phone phone_input phone_input text_input

Display steps (say, header, btw, warning) have no type, so they return nil widget hints by default.

All Recognized Widget Types

Web / graphical (desktop + mobile):

text_input         textarea            number_input
currency_input     toggle              yes_no_buttons
radio_group        dropdown            checkbox_group
multi_select_dropdown  date_picker     email_input
phone_input

TTY — maps to tty-prompt methods:

Widget type tty-prompt method Notes
text_input prompt.ask Single-line text
multiline prompt.multiline Multi-line text (:text type)
number_input prompt.ask With numeric conversion
yes_no prompt.yes? Boolean gate
select prompt.select Single choice from list
multi_select prompt.multi_select Multiple choices from list
enum_select prompt.enum_select Numbered menu
mask prompt.mask Hidden/password input
slider prompt.slider Numeric range slider

Adapters are not required to support every widget type and should fall back gracefully (e.g. radio_groupdropdown in a TTY context).


WidgetHint

WidgetHint is an immutable Data class (Data.define) that pairs a widget type with an options hash:

hint = Inquirex::UI::WidgetHint.new(type: :radio_group, options: { columns: 2 })

hint.type     # => :radio_group
hint.options  # => { columns: 2 }
hint.to_h     # => { "type" => "radio_group", "columns" => 2 }

Inquirex::UI::WidgetHint.from_h({ "type" => "radio_group", "columns" => 2 })
# => #<data Inquirex::UI::WidgetHint type=:radio_group, options={columns: 2}>

The options hash is merged inline with "type" in the serialized form — no extra nesting.


UI::Node

Inquirex::UI::Node extends Inquirex::Node with a widget_hints hash and two accessor methods:

Attribute / Method Returns Description
widget_hints Hash{Symbol => WidgetHint}? All explicit hints keyed by target, or nil for display nodes
widget_hint_for(target:) WidgetHint? Explicit hint for the given target, or nil
effective_widget_hint_for(target:) WidgetHint? Explicit hint or registry default for the given target
step = definition.step(:filing_status)

step.widget_hints
# => { desktop: #<WidgetHint type=:radio_group, options={columns: 2}>,
#      mobile:  #<WidgetHint type=:dropdown, options={}> }

step.widget_hint_for(target: :desktop)
# => #<data WidgetHint type=:radio_group, options={columns: 2}>

step.effective_widget_hint_for(target: :mobile)
# => #<data WidgetHint type=:dropdown, options={}>

Collecting steps produced by Inquirex::UI.define always return a non-nil value from effective_widget_hint_for (registry fills in the gap). Display steps (say, header, btw, warning) return nil.


JSON Serialization

Widget hints round-trip through JSON:

definition.to_json
# => '{"id":"tax-intake","start":"filing_status","steps":{"filing_status":{"verb":"ask",
#      "type":"enum","question":"What is your filing status?","options":[...],"transitions":[...],
#      "widget":{"desktop":{"type":"radio_group","columns":2},"mobile":{"type":"dropdown"},
#               "tty":{"type":"select"}}},...}}'

Wire Format for a Step with Hints

{
  "verb": "ask",
  "type": "enum",
  "question": "What is your filing status?",
  "options": [
    { "value": "single", "label": "Single" },
    { "value": "married_jointly", "label": "Married Filing Jointly" }
  ],
  "transitions": [{ "to": "income" }],
  "widget": {
    "desktop": { "type": "radio_group", "columns": 2 },
    "mobile":  { "type": "dropdown" },
    "tty":     { "type": "select" }
  }
}

There is a single "widget" key whose value is an object keyed by target name. New targets can be added without changing the schema. Display steps (say, header, btw, warning) carry no "widget" key at all.

Deserializing UI Nodes

Inquirex::Definition.from_json restores the base Inquirex::Node class by default (the core gem has no knowledge of inquirex-ui). To restore UI::Node instances:

json = definition.to_json
step_hash = JSON.parse(json)["steps"]["filing_status"]

restored = Inquirex::UI::Node.from_h(:filing_status, step_hash)
restored.widget_hints.type  # => :radio_group

Engine Compatibility

Inquirex::UI.define returns a standard Inquirex::Definition. It works unchanged with Inquirex::Engine:

engine = Inquirex::Engine.new(definition)
engine.answer("single")        # :filing_status → :income
engine.answer(75_000.00)       # :income → :has_deductions
engine.answer(true)            # :has_deductions → :deductions
engine.answer(%w[Mortgage])    # :deductions → :done
engine.advance                 # past :done
engine.finished?               # => true
engine.answers
# => { filing_status: "single", income: 75000.0, has_deductions: true, deductions: ["Mortgage"] }

For Adapter Authors

If you are building an adapter (TTY terminal, JS widget, Rails views):

  1. Parse the flow JSON to get step definitions.
  2. For each collecting step, read step["widget"] — it is an object keyed by target name.
  3. Look up your target (e.g. step["widget"]["tty"]). The "type" key names the widget; additional keys are options.
  4. If "widget" is absent, call WidgetRegistry.default_hint_for(type) (Ruby) or implement the same lookup table in your target language.
  5. Fall back gracefully — not every adapter supports every widget type.
# Example adapter lookup
def widget_for(step_hash, target: :desktop)
  widget_map = step_hash["widget"]
  raw = widget_map&.fetch(target.to_s, nil) || widget_map&.fetch("desktop", nil)
  return Inquirex::UI::WidgetRegistry.default_hint_for(step_hash["type"], context: target) unless raw

  Inquirex::UI::WidgetHint.from_h(raw)
end

Development

bundle install
bundle exec rspec                       # run specs (90 examples)
bundle exec rspec --format documentation
bundle exec rubocop                     # lint

The core inquirex gem is loaded from a relative path (../inquirex) in development. See Gemfile for details.

License

MIT. See LICENSE.txt.