inquirex 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5a1021c83f11e037c69736dd5fa9228d95af53c9f7967173dd21bf9b096d4f31
4
- data.tar.gz: 5b3cec5558da5aa1f9c3850e77ff6021916b9652531862ba621b8a7ea65fd5a7
3
+ metadata.gz: 17d01981ef4e69be2150f96efb297012bbfb2af2b3128ba6aea45f39c9aec821
4
+ data.tar.gz: '0298dbc81566df2384ab1c634303f4504313e6a35a091b559f10ffb668508cb5'
5
5
  SHA512:
6
- metadata.gz: aba4ff0487e52177d96fdf385c3268f4ea3c888c7e0e711547ec7b78700c7b5b377d573235823a5d8fe2861553006bed2b7573cdc9c8c38c2fc480ae0af81aa9
7
- data.tar.gz: d1f7ccf5705755923d240414abb3e688534a618a36f9607fd60d6a526b72886c5d7fd86d8b1b93589edf16f7627c0ac2c26be4d7d42f1a4109f9f0bf1376ee2e
6
+ metadata.gz: c3ddd23f48c9781b11e2692690bf8dfd1dc5b3e764b089167a71053f74950fd2f18465aadc548364dd660c8db8599c092c46eba21eb9f7df80e429d9633210f2
7
+ data.tar.gz: f53e0183422971270e3db8f069206aa8f23d83e8439a6738922d878da5066937693a4c8303a6786cd8347aef4ab6ae3a228acca583cb0a3861616f7ed6e82c37
data/README.md CHANGED
@@ -1,3 +1,5 @@
1
+ [![Ruby](https://github.com/inquirex/inquirex/actions/workflows/main.yml/badge.svg)](https://github.com/inquirex/inquirex/actions/workflows/main.yml)   ![Coverage](docs/badges/coverage_badge.svg)
2
+
1
3
  # Inquirex
2
4
 
3
5
  `inquirex` is a pure Ruby, declarative, rules-driven questionnaire engine for building conditional intake forms, qualification wizards, and branching surveys.
@@ -6,6 +8,8 @@ It is the core gem in the Inquirex ecosystem and focuses on:
6
8
 
7
9
  - A conversational DSL (`ask`, `say`, `header`, `btw`, `warning`, `confirm`)
8
10
  - A serializable AST rule system (`contains`, `equals`, `greater_than`, `less_than`, `not_empty`, `all`, `any`)
11
+ - Framework-agnostic widget rendering hints (`widget` DSL verb, `WidgetHint`, `WidgetRegistry`)
12
+ - Named **accumulators** for running totals (pricing, complexity scoring, credit scoring, lead qualification)
9
13
  - An immutable flow definition graph
10
14
  - A runtime engine for stateful step traversal
11
15
  - JSON round-trip serialization for cross-platform clients
@@ -13,10 +17,10 @@ It is the core gem in the Inquirex ecosystem and focuses on:
13
17
 
14
18
  ## Status
15
19
 
16
- - Version: `0.1.0`
20
+ - Version: `0.2.0`
17
21
  - Ruby: `>= 4.0.0` (project currently uses `4.0.2`)
18
- - Test suite: `109 examples, 0 failures`
19
- - Coverage: ~`92.5%` line coverage
22
+ - Test suite: `220 examples, 0 failures`
23
+ - Coverage: ~`94%` line coverage
20
24
 
21
25
  ## Why Inquirex
22
26
 
@@ -56,6 +60,8 @@ definition = Inquirex.define id: "tax-intake-2025", version: "1.0.0" do
56
60
  type :enum
57
61
  question "What is your filing status?"
58
62
  options single: "Single", married_jointly: "Married Filing Jointly"
63
+ widget target: :desktop, type: :radio_group, columns: 2
64
+ widget target: :mobile, type: :dropdown
59
65
  transition to: :dependents
60
66
  end
61
67
 
@@ -98,7 +104,8 @@ engine.finished? # => true
98
104
  ### Flow-level methods
99
105
 
100
106
  - `start :step_id` sets the entry step
101
- - `meta title:, subtitle:, brand:` adds optional frontend metadata
107
+ - `meta title:, subtitle:, brand:, theme:` adds optional frontend metadata (see [Theme](#theme-and-branding))
108
+ - `accumulator :name, type:, default:` declares a running total (see [Accumulators](#accumulators))
102
109
 
103
110
  ### Step verbs
104
111
 
@@ -128,6 +135,212 @@ engine.finished? # => true
128
135
  - `skip_if rule`
129
136
  - `transition to: :next_step, if_rule: rule, requires_server: false`
130
137
  - `compute { |answers| ... }` (accepted by the DSL as a server-side hook; currently omitted from runtime JSON)
138
+ - `widget target: :desktop, type: :radio_group, columns: 2` (rendering hint for frontend adapters)
139
+ - `accumulate :name, lookup:|per_selection:|per_unit:|flat:` (contribution to a named running total; see [Accumulators](#accumulators))
140
+ - `price ...` (sugar for `accumulate :price, ...`)
141
+
142
+ ## Widget Rendering Hints
143
+
144
+ Every collecting step can carry framework-agnostic rendering hints via the `widget` DSL verb. Frontend adapters (JS widget, TTY, Rails) use these to pick the right UI control.
145
+
146
+ ```ruby
147
+ ask :priority do
148
+ type :enum
149
+ question "How urgent is this?"
150
+ options low: "Low", medium: "Medium", high: "High"
151
+ widget target: :desktop, type: :radio_group, columns: 3
152
+ widget target: :mobile, type: :dropdown
153
+ widget target: :tty, type: :select
154
+ transition to: :next_step
155
+ end
156
+ ```
157
+
158
+ When no explicit `widget` is set, `WidgetRegistry` fills in sensible defaults per data type:
159
+
160
+ | Data Type | Desktop | Mobile | TTY |
161
+ |-----------|---------|--------|-----|
162
+ | `:enum` | `radio_group` | `dropdown` | `select` |
163
+ | `:multi_enum` | `checkbox_group` | `checkbox_group` | `multi_select` |
164
+ | `:boolean` | `toggle` | `yes_no_buttons` | `yes_no` |
165
+ | `:string` | `text_input` | `text_input` | `text_input` |
166
+ | `:text` | `textarea` | `textarea` | `multiline` |
167
+ | `:integer` | `number_input` | `number_input` | `number_input` |
168
+ | `:currency` | `currency_input` | `currency_input` | `number_input` |
169
+ | `:date` | `date_picker` | `date_picker` | `text_input` |
170
+
171
+ Display verbs (`say`, `header`, `btw`, `warning`) have no widget hints.
172
+
173
+ Widget hints are included in JSON serialization under a `"widget"` key, keyed by target:
174
+
175
+ ```json
176
+ "widget": {
177
+ "desktop": { "type": "radio_group", "columns": 3 },
178
+ "mobile": { "type": "dropdown" },
179
+ "tty": { "type": "select" }
180
+ }
181
+ ```
182
+
183
+ ### Accessing Hints at Runtime
184
+
185
+ ```ruby
186
+ step = definition.step(:priority)
187
+ step.widget_hint_for(target: :desktop) # explicit hint or nil
188
+ step.effective_widget_hint_for(target: :desktop) # explicit hint or registry default
189
+ ```
190
+
191
+ > **Note:** Widget hints were previously in a separate `inquirex-ui` gem. As of v0.2.0 they are part of core, since every frontend adapter needs them.
192
+
193
+ ## Accumulators
194
+
195
+ Accumulators are **named running totals** that a flow maintains as the user answers questions. The canonical use case is **pricing** — totalling the cost of a tax return, a SaaS quote, or an insurance premium — but the same primitive generalizes to **complexity scoring**, **credit scoring**, **lead qualification scores**, **risk scores**, or any other numeric tally.
196
+
197
+ Like rules, accumulator declarations are **pure data**. They serialize to JSON and evaluate identically on the Ruby server and in the JS widget — no lambdas, no server round-trips.
198
+
199
+ ### Declaring accumulators
200
+
201
+ Each flow declares one or more accumulators with a name, a type, and a starting value:
202
+
203
+ ```ruby
204
+ Inquirex.define id: "tax-pricing-2025" do
205
+ accumulator :price, type: :currency, default: 0
206
+ accumulator :complexity, type: :integer, default: 0
207
+ # ...
208
+ end
209
+ ```
210
+
211
+ ### Contributing to an accumulator from a step
212
+
213
+ Use the `accumulate` verb inside any `ask`/`confirm` step. Exactly one **shape** key must be provided:
214
+
215
+ | Shape | Fits | Semantics |
216
+ |-------|------|-----------|
217
+ | `lookup: { ... }` | `:enum` | Adds the amount mapped to the chosen option value |
218
+ | `per_selection: { ... }` | `:multi_enum` | Sums the amounts for every selected option |
219
+ | `per_unit: N` | `:integer`, `:decimal` | Multiplies the numeric answer by `N` |
220
+ | `flat: N` | any type | Adds `N` when the step has a truthy, non-empty answer |
221
+
222
+ ```ruby
223
+ ask :filing_status do
224
+ type :enum
225
+ question "Filing status?"
226
+ options single: "Single", mfj: "Married Filing Jointly", hoh: "Head of Household"
227
+ accumulate :price, lookup: { single: 200, mfj: 400, hoh: 300 }
228
+ accumulate :complexity, lookup: { mfj: 1 }
229
+ transition to: :dependents
230
+ end
231
+
232
+ ask :dependents do
233
+ type :integer
234
+ question "How many dependents?"
235
+ default 0
236
+ accumulate :price, per_unit: 25 # $25 per dependent
237
+ transition to: :schedules
238
+ end
239
+
240
+ ask :schedules do
241
+ type :multi_enum
242
+ question "Which schedules apply?"
243
+ options c: "Schedule C (Business)",
244
+ e: "Schedule E (Rental)",
245
+ d: "Schedule D (Capital Gains)"
246
+ accumulate :price, per_selection: { c: 150, e: 75, d: 50 }
247
+ accumulate :complexity, per_selection: { c: 2, e: 1, d: 1 }
248
+ transition to: :done
249
+ end
250
+ ```
251
+
252
+ A single step can contribute to any number of accumulators.
253
+
254
+ ### The `price` sugar
255
+
256
+ Since `:price` is the most common use case (lead qualification, tax prep, SaaS quotes), there's a one-liner:
257
+
258
+ ```ruby
259
+ price single: 200, mfj: 400, hoh: 300 # => accumulate :price, lookup: { ... }
260
+ price per_unit: 25 # => accumulate :price, per_unit: 25
261
+ price per_selection: { c: 150, e: 75 } # => accumulate :price, per_selection: { ... }
262
+ ```
263
+
264
+ If you pass a plain option-value → amount hash (no shape key), `price` treats it as a `lookup`.
265
+
266
+ ### Reading totals at runtime
267
+
268
+ The engine maintains running totals as each answer comes in:
269
+
270
+ ```ruby
271
+ engine = Inquirex::Engine.new(definition)
272
+ engine.answer("mfj") # filing_status: +$400, +1 complexity
273
+ engine.answer(3) # dependents: +$75
274
+ engine.answer(%w[c e]) # schedules: +$225, +3 complexity
275
+
276
+ engine.total(:price) # => 700.0
277
+ engine.total(:complexity) # => 4
278
+ engine.totals # => { price: 700.0, complexity: 4 }
279
+ ```
280
+
281
+ `#to_state` includes `totals:` so persisted sessions resume with the correct running total.
282
+
283
+ ### JSON wire format
284
+
285
+ Accumulators serialize predictably, keeping the contract with `inquirex-js` explicit:
286
+
287
+ ```json
288
+ {
289
+ "accumulators": {
290
+ "price": { "type": "currency", "default": 0 },
291
+ "complexity": { "type": "integer", "default": 0 }
292
+ },
293
+ "steps": {
294
+ "filing_status": {
295
+ "verb": "ask",
296
+ "type": "enum",
297
+ "accumulate": {
298
+ "price": { "lookup": { "single": 200, "mfj": 400, "hoh": 300 } },
299
+ "complexity": { "lookup": { "mfj": 1 } }
300
+ }
301
+ },
302
+ "dependents": {
303
+ "verb": "ask",
304
+ "type": "integer",
305
+ "accumulate": { "price": { "per_unit": 25 } }
306
+ },
307
+ "schedules": {
308
+ "verb": "ask",
309
+ "type": "multi_enum",
310
+ "accumulate": {
311
+ "price": { "per_selection": { "c": 150, "e": 75, "d": 50 } },
312
+ "complexity": { "per_selection": { "c": 2, "e": 1, "d": 1 } }
313
+ }
314
+ }
315
+ }
316
+ }
317
+ ```
318
+
319
+ The `inquirex-js` widget reads this verbatim and reproduces the same totals client-side.
320
+
321
+ ## Theme and Branding
322
+
323
+ The flow's `meta` hash carries optional branding and theme overrides for the JS widget. Identity (name, logo) goes in `brand:`; colors, fonts, and radii go in `theme:`.
324
+
325
+ ```ruby
326
+ meta title: "Tax Preparation Intake",
327
+ subtitle: "Let's understand your situation",
328
+ brand: { name: "Agentica", logo: "https://cdn.example.com/logo.png" },
329
+ theme: {
330
+ brand: "#2563eb",
331
+ on_brand: "#ffffff",
332
+ background: "#0b1020",
333
+ surface: "#111827",
334
+ text: "#f9fafb",
335
+ text_muted: "#94a3b8",
336
+ border: "#1f2937",
337
+ radius: "18px",
338
+ font: "Inter, system-ui, sans-serif",
339
+ header_font: "Inter, system-ui, sans-serif"
340
+ }
341
+ ```
342
+
343
+ Snake-case keys (`on_brand`, `text_muted`, `header_font`) are idiomatic Ruby; they're automatically translated to the camelCase names (`onBrand`, `textMuted`, `headerFont`) the JS widget expects on the wire. Each theme key maps 1:1 to a CSS custom property on the widget's shadow root.
131
344
 
132
345
  ## Rule System (AST, JSON-serializable)
133
346
 
@@ -159,13 +372,15 @@ transition to: :complex_path,
159
372
  - `current_step`
160
373
  - `answers` (raw hash)
161
374
  - `history` (visited step IDs)
375
+ - `totals` (running totals per accumulator — see [Accumulators](#accumulators))
162
376
 
163
377
  Behavior:
164
378
 
165
379
  - Use `answer(value)` on collecting steps
166
380
  - Use `advance` on display steps
167
381
  - Use `finished?` to detect completion
168
- - Use `to_state` / `.from_state` for persistence/resume
382
+ - Use `total(:price)` / `totals` to read running totals
383
+ - Use `to_state` / `.from_state` for persistence/resume (totals included)
169
384
 
170
385
  ### Validation Adapter
171
386
 
@@ -192,14 +407,18 @@ restored = Inquirex::Definition.from_json(json)
192
407
  Serialized structure includes:
193
408
 
194
409
  - Flow metadata (`id`, `version`, `meta`, `start`)
410
+ - Branding and theme (`meta.brand`, `meta.theme`)
411
+ - Accumulator declarations (`accumulators`) and per-step contributions (`accumulate`)
195
412
  - Steps and transitions
196
413
  - Rule AST payloads
414
+ - Widget hints
197
415
 
198
416
  Important serialization details:
199
417
 
200
- - Rule objects serialize and deserialize cleanly
418
+ - Rule objects and accumulator shapes serialize and deserialize cleanly
201
419
  - Proc/lambda defaults are stripped from JSON
202
420
  - `requires_server: true` transition flag is preserved
421
+ - Snake-case theme keys are converted to camelCase on serialization to match the JS widget contract
203
422
 
204
423
  ## Answers Wrapper
205
424
 
@@ -274,15 +493,27 @@ just lint-fix
274
493
  just ci
275
494
  ```
276
495
 
277
- ## Roadmap Context
496
+ ## Extension Gems
497
+
498
+ The core gem is designed to be extended by optional gems that inject new DSL verbs at `require` time:
499
+
500
+ ```ruby
501
+ require "inquirex" # core DSL, rules, engine, widget hints
502
+ require "inquirex-llm" # adds: clarify, describe, summarize, detour
503
+
504
+ Inquirex.define do # one entry point, all verbs available
505
+ # ...
506
+ end
507
+ ```
508
+
509
+ ### Ecosystem
278
510
 
279
- This repository is the core runtime (`inquirex`). The full ecosystem roadmap includes:
511
+ - **`inquirex-llm`** -- LLM-powered verbs (`clarify`, `describe`, `summarize`, `detour`) for server-side AI processing
512
+ - **`inquirex-tty`** -- terminal adapter using TTY Toolkit
513
+ - **`inquirex-js`** -- embeddable browser widget (chat-style)
514
+ - **`inquirex-rails`** -- Rails Engine for persistence, API, and asset serving
280
515
 
281
- - `inquirex-ui` for rendering metadata
282
- - `inquirex-tty` for terminal interaction
283
- - `inquirex-js` for embeddable web widget runtime
284
- - `inquirex-llm` for server-side LLM verbs
285
- - `inquirex-rails` for persistence/API integration
516
+ > **Note:** `inquirex-ui` has been merged into core as of v0.2.0. Widget hints (`widget` DSL verb, `WidgetHint`, `WidgetRegistry`) are now built in.
286
517
 
287
518
  ## License
288
519
 
@@ -0,0 +1,21 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <svg xmlns="http://www.w3.org/2000/svg" width="99" height="20">
3
+ <linearGradient id="b" x2="0" y2="100%">
4
+ <stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
5
+ <stop offset="1" stop-opacity=".1"/>
6
+ </linearGradient>
7
+ <mask id="a">
8
+ <rect width="99" height="20" rx="3" fill="#fff"/>
9
+ </mask>
10
+ <g mask="url(#a)">
11
+ <path fill="#555" d="M0 0h63v20H0z"/>
12
+ <path fill="#4c1" d="M63 0h36v20H63z"/>
13
+ <path fill="url(#b)" d="M0 0h99v20H0z"/>
14
+ </g>
15
+ <g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
16
+ <text x="31.5" y="15" fill="#010101" fill-opacity=".3">coverage</text>
17
+ <text x="31.5" y="14">coverage</text>
18
+ <text x="80" y="15" fill="#010101" fill-opacity=".3">94%</text>
19
+ <text x="80" y="14">94%</text>
20
+ </g>
21
+ </svg>
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Inquirex
4
+ # Declares a named running total (e.g. :price, :complexity, :credit_score) that
5
+ # flows accumulate into as the user answers questions. Pure data, serializable
6
+ # to JSON, evaluated identically on Ruby and JS sides.
7
+ #
8
+ # @attr_reader name [Symbol] accumulator identifier (e.g. :price)
9
+ # @attr_reader type [Symbol] one of Node::TYPES (typically :currency, :integer, :decimal)
10
+ # @attr_reader default [Numeric] starting value (default: 0)
11
+ class Accumulator
12
+ attr_reader :name, :type, :default
13
+
14
+ def initialize(name:, type: :decimal, default: 0)
15
+ @name = name.to_sym
16
+ @type = type.to_sym
17
+ @default = default
18
+ freeze
19
+ end
20
+
21
+ def to_h
22
+ { "type" => @type.to_s, "default" => @default }
23
+ end
24
+
25
+ def self.from_h(name, hash)
26
+ new(
27
+ name: name,
28
+ type: hash["type"] || hash[:type] || :decimal,
29
+ default: hash["default"] || hash[:default] || 0
30
+ )
31
+ end
32
+ end
33
+
34
+ # Per-step declaration of how a single answer contributes to one accumulator.
35
+ # A Node may carry zero or more Accumulation entries, one per target accumulator.
36
+ #
37
+ # Supported shapes (exactly one must be set):
38
+ # lookup: Hash of answer_value => amount (for :enum)
39
+ # per_selection: Hash of option_value => amount (for :multi_enum, summed)
40
+ # per_unit: Numeric rate multiplied by the answer (for numeric types)
41
+ # flat: Numeric amount added if the step has any answer
42
+ #
43
+ # @attr_reader target [Symbol] accumulator name to contribute to (e.g. :price)
44
+ # @attr_reader shape [Symbol] one of :lookup, :per_selection, :per_unit, :flat
45
+ # @attr_reader payload [Object] shape-specific data (Hash or Numeric)
46
+ class Accumulation
47
+ SHAPES = %i[lookup per_selection per_unit flat].freeze
48
+
49
+ attr_reader :target, :shape, :payload
50
+
51
+ def initialize(target:, shape:, payload:)
52
+ @target = target.to_sym
53
+ @shape = shape.to_sym
54
+ raise Errors::DefinitionError, "Unknown accumulator shape: #{shape.inspect}" unless SHAPES.include?(@shape)
55
+
56
+ @payload = self.class.send(:normalize_payload, @shape, payload).freeze
57
+ freeze
58
+ end
59
+
60
+ # Computes this accumulation's contribution given the step's answer.
61
+ # Returns 0 when the answer is absent or does not match the shape.
62
+ #
63
+ # @param answer [Object] the user's answer for the owning step
64
+ # @return [Numeric]
65
+ def contribution(answer)
66
+ return 0 if answer.nil?
67
+
68
+ case @shape
69
+ when :lookup then lookup_amount(answer)
70
+ when :per_selection then selection_amount(answer)
71
+ when :per_unit then unit_amount(answer)
72
+ when :flat then flat_amount(answer)
73
+ end
74
+ end
75
+
76
+ def to_h
77
+ { @shape.to_s => serialize_payload }
78
+ end
79
+
80
+ # Parses a single-key Hash like `{ "lookup" => {...} }` into an Accumulation.
81
+ #
82
+ # @param target [Symbol, String] accumulator name
83
+ # @param hash [Hash] serialized shape (string or symbol keys)
84
+ # @return [Accumulation]
85
+ def self.from_h(target, hash)
86
+ pair = hash.to_a.find { |k, _| SHAPES.include?(k.to_sym) }
87
+ raise Errors::SerializationError, "Invalid accumulation entry: #{hash.inspect}" unless pair
88
+
89
+ shape_key, payload = pair
90
+ new(target: target, shape: shape_key.to_sym, payload: payload)
91
+ end
92
+
93
+ def self.normalize_payload(shape, payload)
94
+ case shape
95
+ when :lookup, :per_selection
96
+ (payload || {}).each_with_object({}) { |(k, v), acc| acc[k.to_s] = v }
97
+ else
98
+ payload
99
+ end
100
+ end
101
+ private_class_method :normalize_payload
102
+
103
+ private
104
+
105
+ def serialize_payload
106
+ case @shape
107
+ when :lookup, :per_selection
108
+ @payload.each_with_object({}) { |(k, v), acc| acc[k.to_s] = v }
109
+ else
110
+ @payload
111
+ end
112
+ end
113
+
114
+ def lookup_amount(answer)
115
+ @payload[answer.to_s] || 0
116
+ end
117
+
118
+ def selection_amount(answer)
119
+ return 0 unless answer.is_a?(Array)
120
+
121
+ answer.sum { |selected| @payload[selected.to_s] || 0 }
122
+ end
123
+
124
+ def unit_amount(answer)
125
+ numeric = numeric_for(answer)
126
+ return 0 if numeric.nil?
127
+
128
+ numeric * @payload
129
+ end
130
+
131
+ def flat_amount(answer)
132
+ return 0 if answer == false
133
+ return 0 if answer.respond_to?(:empty?) && answer.empty?
134
+
135
+ @payload
136
+ end
137
+
138
+ def numeric_for(value)
139
+ case value
140
+ when Numeric then value
141
+ when String then Float(value, exception: false)
142
+ end
143
+ end
144
+ end
145
+ end
@@ -13,20 +13,22 @@ module Inquirex
13
13
  # @attr_reader start_step_id [Symbol] id of the first step in the flow
14
14
  # @attr_reader steps [Hash<Symbol, Node>] frozen map of step id => node
15
15
  class Definition
16
- attr_reader :id, :version, :meta, :start_step_id, :steps
16
+ attr_reader :id, :version, :meta, :start_step_id, :steps, :accumulators
17
17
 
18
18
  # @param start_step_id [Symbol] id of the initial step
19
19
  # @param nodes [Hash<Symbol, Node>] all steps keyed by id
20
20
  # @param id [String, nil] flow identifier
21
21
  # @param version [String] semver
22
22
  # @param meta [Hash] frontend metadata
23
+ # @param accumulators [Hash<Symbol, Accumulator>] named running totals
23
24
  # @raise [Errors::DefinitionError] if start_step_id is not present in nodes
24
- def initialize(start_step_id:, nodes:, id: nil, version: "1.0.0", meta: {})
25
+ def initialize(start_step_id:, nodes:, id: nil, version: "1.0.0", meta: {}, accumulators: {})
25
26
  @id = id
26
27
  @version = version
27
28
  @meta = meta.freeze
28
29
  @start_step_id = start_step_id.to_sym
29
30
  @steps = nodes.freeze
31
+ @accumulators = accumulators.freeze
30
32
  validate!
31
33
  freeze
32
34
  end
@@ -65,6 +67,11 @@ module Inquirex
65
67
  hash["version"] = @version
66
68
  hash["meta"] = @meta unless @meta.empty?
67
69
  hash["start"] = @start_step_id.to_s
70
+ unless @accumulators.empty?
71
+ hash["accumulators"] = @accumulators.each_with_object({}) do |(name, acc), h|
72
+ h[name.to_s] = acc.to_h
73
+ end
74
+ end
68
75
  hash["steps"] = @steps.transform_keys(&:to_s).transform_values(&:to_h)
69
76
  hash
70
77
  end
@@ -89,13 +96,19 @@ module Inquirex
89
96
  meta = hash["meta"] || hash[:meta] || {}
90
97
  start = hash["start"] || hash[:start]
91
98
  steps_data = hash["steps"] || hash[:steps] || {}
99
+ acc_data = hash["accumulators"] || hash[:accumulators] || {}
92
100
 
93
101
  nodes = steps_data.each_with_object({}) do |(step_id, step_hash), acc|
94
102
  sym_id = step_id.to_sym
95
103
  acc[sym_id] = Node.from_h(sym_id, step_hash)
96
104
  end
97
105
 
98
- new(start_step_id: start, nodes:, id:, version:, meta:)
106
+ accumulators = acc_data.each_with_object({}) do |(name, entry), h|
107
+ sym = name.to_sym
108
+ h[sym] = Accumulator.from_h(sym, entry)
109
+ end
110
+
111
+ new(start_step_id: start, nodes:, id:, version:, meta:, accumulators:)
99
112
  end
100
113
 
101
114
  private
@@ -15,6 +15,19 @@ module Inquirex
15
15
  @start_step_id = nil
16
16
  @nodes = {}
17
17
  @meta = {}
18
+ @accumulators = {}
19
+ end
20
+
21
+ # Declares a named running total the flow accumulates into as answers come in.
22
+ # The `:price` accumulator is the common lead-qualification use case; others
23
+ # (e.g. :complexity, :credit_score) work identically.
24
+ #
25
+ # @param name [Symbol] e.g. :price
26
+ # @param type [Symbol] one of Node::TYPES (default :currency-ish: :decimal)
27
+ # @param default [Numeric] starting value (default: 0)
28
+ def accumulator(name, type: :decimal, default: 0)
29
+ sym = name.to_sym
30
+ @accumulators[sym] = Accumulator.new(name: sym, type:, default:)
18
31
  end
19
32
 
20
33
  # Sets the entry step id for the flow.
@@ -24,15 +37,28 @@ module Inquirex
24
37
  @start_step_id = step_id
25
38
  end
26
39
 
27
- # Sets frontend metadata: title, subtitle, and brand information.
40
+ # Sets frontend metadata: title, subtitle, brand, and theme.
28
41
  #
29
42
  # @param title [String, nil]
30
43
  # @param subtitle [String, nil]
31
- # @param brand [Hash, nil] e.g. { name: "Acme", color: "#2563eb" }
32
- def meta(title: nil, subtitle: nil, brand: nil)
44
+ # @param brand [Hash, nil] identity — `{ name: "Acme", logo: "https://..." }`.
45
+ # Colors and fonts belong in `theme:`, not here.
46
+ # @param theme [Hash, nil] visual overrides consumed by the JS widget.
47
+ # Every key maps 1:1 to a CSS custom property on the widget's shadow root.
48
+ # Supported keys: :brand, :on_brand (or :onBrand), :background, :surface,
49
+ # :text, :text_muted (or :textMuted), :border, :radius, :font,
50
+ # :header_font (or :headerFont).
51
+ #
52
+ # @example
53
+ # meta title: "Tax Intake",
54
+ # subtitle: "Let's get started",
55
+ # brand: { name: "Agentica", logo: "https://cdn.example.com/logo.png" },
56
+ # theme: { brand: "#2563eb", radius: "18px", font: "Inter, system-ui" }
57
+ def meta(title: nil, subtitle: nil, brand: nil, theme: nil)
33
58
  @meta[:title] = title if title
34
59
  @meta[:subtitle] = subtitle if subtitle
35
60
  @meta[:brand] = brand if brand
61
+ @meta[:theme] = normalize_theme(theme) if theme
36
62
  end
37
63
 
38
64
  # Defines a question step that collects typed input from the user.
@@ -91,12 +117,28 @@ module Inquirex
91
117
  nodes: @nodes,
92
118
  id: @flow_id,
93
119
  version: @flow_version,
94
- meta: @meta
120
+ meta: @meta,
121
+ accumulators: @accumulators
95
122
  )
96
123
  end
97
124
 
125
+ # Maps snake_case theme keys (idiomatic in Ruby) to the camelCase names
126
+ # the JS widget (inquirex-js ThemeOverrides) expects on the wire.
127
+ THEME_KEY_ALIASES = {
128
+ on_brand: :onBrand,
129
+ text_muted: :textMuted,
130
+ header_font: :headerFont
131
+ }.freeze
132
+
98
133
  private
99
134
 
135
+ def normalize_theme(theme)
136
+ theme.each_with_object({}) do |(key, value), acc|
137
+ sym = key.to_sym
138
+ acc[THEME_KEY_ALIASES.fetch(sym, sym)] = value
139
+ end
140
+ end
141
+
100
142
  def add_step(id, verb, &block)
101
143
  builder = StepBuilder.new(verb)
102
144
  builder.instance_eval(&block) if block
@@ -18,6 +18,41 @@ module Inquirex
18
18
  @skip_if = nil
19
19
  @default = nil
20
20
  @compute = nil
21
+ @widget_hints = {}
22
+ @accumulations = []
23
+ end
24
+
25
+ # Declares how this step's answer contributes to a named accumulator.
26
+ # Exactly one shape key should be provided:
27
+ # lookup: Hash of answer_value => amount (for :enum)
28
+ # per_selection: Hash of option_value => amount (for :multi_enum)
29
+ # per_unit: Numeric rate (multiplied by the numeric answer)
30
+ # flat: Numeric (added when the step has any answer)
31
+ #
32
+ # @example
33
+ # accumulate :price, lookup: { single: 200, mfj: 400 }
34
+ # accumulate :price, per_unit: 25
35
+ # accumulate :price, per_selection: { c: 150, e: 75 }
36
+ # accumulate :complexity, flat: 1
37
+ def accumulate(target, lookup: nil, per_selection: nil, per_unit: nil, flat: nil)
38
+ shape, payload = pick_accumulator_shape(lookup:, per_selection:, per_unit:, flat:)
39
+ @accumulations << Accumulation.new(target:, shape:, payload:)
40
+ end
41
+
42
+ # Sugar for the common `:price` accumulator. Accepts either a shape keyword
43
+ # (`lookup:`, `per_selection:`, `per_unit:`, `flat:`) or — when given plain
44
+ # option=>amount keys — treats it as a `lookup`. So both work:
45
+ #
46
+ # price single: 200, mfj: 400 # => lookup
47
+ # price per_unit: 25 # => per_unit
48
+ # price lookup: { single: 200, ... } # => lookup (explicit)
49
+ def price(**kwargs)
50
+ shape_keys = %i[lookup per_selection per_unit flat]
51
+ if kwargs.keys.intersect?(shape_keys)
52
+ accumulate(:price, **kwargs.slice(*shape_keys))
53
+ else
54
+ accumulate(:price, lookup: kwargs)
55
+ end
21
56
  end
22
57
 
23
58
  # Sets the input data type for :ask steps.
@@ -45,6 +80,21 @@ module Inquirex
45
80
  @options = list
46
81
  end
47
82
 
83
+ # Sets a rendering hint for the given target context.
84
+ # Recognized targets: :desktop, :mobile, :tty (and any future targets).
85
+ #
86
+ # @param type [Symbol] widget type (e.g. :radio_group, :dropdown)
87
+ # @param target [Symbol] rendering context (default: :desktop)
88
+ # @param opts [Hash] widget-specific options (e.g. columns: 2)
89
+ #
90
+ # @example
91
+ # widget target: :desktop, type: :radio_group, columns: 2
92
+ # widget target: :mobile, type: :dropdown
93
+ # widget target: :tty, type: :select
94
+ def widget(type:, target: :desktop, **opts)
95
+ @widget_hints[target.to_sym] = WidgetHint.new(type:, options: opts)
96
+ end
97
+
48
98
  # Adds a conditional transition. First matching transition wins.
49
99
  #
50
100
  # @param to [Symbol] target step id
@@ -85,25 +135,50 @@ module Inquirex
85
135
  def build(id)
86
136
  Node.new(
87
137
  id:,
88
- verb: @verb,
89
- type: resolve_type,
90
- question: @question,
91
- text: @text,
92
- options: @options,
93
- transitions: @transitions,
94
- skip_if: @skip_if,
95
- default: @default
138
+ verb: @verb,
139
+ type: resolve_type,
140
+ question: @question,
141
+ text: @text,
142
+ options: @options,
143
+ transitions: @transitions,
144
+ skip_if: @skip_if,
145
+ default: @default,
146
+ widget_hints: resolve_widget_hints,
147
+ accumulations: @accumulations
96
148
  )
97
149
  end
98
150
 
99
151
  private
100
152
 
153
+ def pick_accumulator_shape(lookup:, per_selection:, per_unit:, flat:)
154
+ provided = { lookup:, per_selection:, per_unit:, flat: }.compact
155
+ if provided.size != 1
156
+ raise Errors::DefinitionError,
157
+ "accumulate requires exactly one of :lookup, :per_selection, :per_unit, :flat (got #{provided.keys})"
158
+ end
159
+ provided.first
160
+ end
161
+
101
162
  def resolve_type
102
163
  # :confirm is sugar for :ask with type :boolean
103
164
  return :boolean if @verb == :confirm && @type.nil?
104
165
 
105
166
  @type
106
167
  end
168
+
169
+ # Fills in registry defaults for targets not explicitly set (collecting steps only).
170
+ # Display verbs get nil widget_hints.
171
+ def resolve_widget_hints
172
+ effective_type = resolve_type
173
+ return nil if effective_type.nil? && @widget_hints.empty?
174
+
175
+ hints = @widget_hints.dup
176
+ %i[desktop mobile].each do |target|
177
+ hints[target] ||= WidgetRegistry.default_hint_for(effective_type, context: target)
178
+ end
179
+ hints.compact!
180
+ hints.empty? ? nil : hints
181
+ end
107
182
  end
108
183
  end
109
184
  end
@@ -8,7 +8,8 @@ module Inquirex
8
8
  SYMBOLIZERS = {
9
9
  current_step_id: ->(v) { v&.to_sym },
10
10
  history: ->(v) { Array(v).map { |e| e&.to_sym } },
11
- answers: ->(v) { symbolize_answers(v) }
11
+ answers: ->(v) { symbolize_answers(v) },
12
+ totals: ->(v) { symbolize_answers(v) }
12
13
  }.freeze
13
14
 
14
15
  # Normalizes a state hash so step ids and history entries are symbols.
@@ -10,7 +10,7 @@ module Inquirex
10
10
  # Validates each answer via an optional Validation::Adapter, then advances using
11
11
  # node transitions. Skips steps whose skip_if rule evaluates to true.
12
12
  class Engine
13
- attr_reader :definition, :answers, :history, :current_step_id
13
+ attr_reader :definition, :answers, :history, :current_step_id, :totals
14
14
 
15
15
  # @param definition [Definition] the flow to run
16
16
  # @param validator [Validation::Adapter] optional (default: NullAdapter)
@@ -20,10 +20,19 @@ module Inquirex
20
20
  @history = []
21
21
  @current_step_id = definition.start_step_id
22
22
  @validator = validator
23
+ @totals = init_totals
23
24
  @history << @current_step_id
24
25
  skip_display_steps_if_needed
25
26
  end
26
27
 
28
+ # Convenience accessor for a single accumulator's running total.
29
+ #
30
+ # @param name [Symbol] accumulator name (e.g. :price)
31
+ # @return [Numeric]
32
+ def total(name)
33
+ @totals[name.to_sym] || 0
34
+ end
35
+
27
36
  # @return [Node, nil] current step node, or nil if flow is finished
28
37
  def current_step
29
38
  return nil if finished?
@@ -52,6 +61,7 @@ module Inquirex
52
61
  raise Errors::ValidationError, "Validation failed: #{result.errors.join(", ")}" unless result.valid?
53
62
 
54
63
  @answers[@current_step_id] = value
64
+ apply_accumulations(current_step, value)
55
65
  advance_step
56
66
  end
57
67
 
@@ -71,7 +81,8 @@ module Inquirex
71
81
  {
72
82
  current_step_id: @current_step_id,
73
83
  answers: @answers,
74
- history: @history
84
+ history: @history,
85
+ totals: @totals
75
86
  }
76
87
  end
77
88
 
@@ -96,6 +107,20 @@ module Inquirex
96
107
  @current_step_id = state[:current_step_id]
97
108
  @answers = state[:answers] || {}
98
109
  @history = state[:history] || []
110
+ @totals = state[:totals] || init_totals
111
+ end
112
+
113
+ def init_totals
114
+ @definition.accumulators.each_with_object({}) do |(name, acc), h|
115
+ h[name] = acc.default
116
+ end
117
+ end
118
+
119
+ def apply_accumulations(node, answer)
120
+ node.accumulations.each do |accumulation|
121
+ @totals[accumulation.target] ||= 0
122
+ @totals[accumulation.target] += accumulation.contribution(answer)
123
+ end
99
124
  end
100
125
 
101
126
  def advance_step
data/lib/inquirex/node.rb CHANGED
@@ -23,6 +23,7 @@ module Inquirex
23
23
  # @attr_reader transitions [Array<Transition>] ordered conditional next-step edges
24
24
  # @attr_reader skip_if [Rules::Base, nil] rule to skip this step entirely
25
25
  # @attr_reader default [Object, nil] default value (pre-fill, user can change)
26
+ # @attr_reader widget_hints [Hash{Symbol => WidgetHint}, nil] rendering hints per target
26
27
  class Node
27
28
  # Valid DSL verbs and which ones collect input from the user.
28
29
  VERBS = %i[ask say header btw warning confirm].freeze
@@ -44,7 +45,9 @@ module Inquirex
44
45
  :option_labels,
45
46
  :transitions,
46
47
  :skip_if,
47
- :default
48
+ :default,
49
+ :widget_hints,
50
+ :accumulations
48
51
 
49
52
  def initialize(id:,
50
53
  verb:,
@@ -54,7 +57,9 @@ module Inquirex
54
57
  options: nil,
55
58
  transitions: [],
56
59
  skip_if: nil,
57
- default: nil)
60
+ default: nil,
61
+ widget_hints: nil,
62
+ accumulations: [])
58
63
  @id = id.to_sym
59
64
  @verb = verb.to_sym
60
65
  @type = type&.to_sym
@@ -63,10 +68,11 @@ module Inquirex
63
68
  @transitions = transitions.freeze
64
69
  @skip_if = skip_if
65
70
  @default = default
71
+ @widget_hints = widget_hints&.freeze
72
+ @accumulations = accumulations.freeze
66
73
  extract_options(options)
67
74
  freeze
68
75
  end
69
- # rubocop:enable Metrics/ParameterLists
70
76
 
71
77
  # @return [Boolean] true if this step collects input from the user
72
78
  def collecting?
@@ -78,6 +84,23 @@ module Inquirex
78
84
  DISPLAY_VERBS.include?(@verb)
79
85
  end
80
86
 
87
+ # Returns the explicit widget hint for the given target, or nil.
88
+ #
89
+ # @param target [Symbol] e.g. :desktop, :mobile, :tty
90
+ # @return [WidgetHint, nil]
91
+ def widget_hint_for(target: :desktop)
92
+ @widget_hints&.fetch(target.to_sym, nil)
93
+ end
94
+
95
+ # Returns the explicit hint for the target, falling back to the
96
+ # registry default for this node's type when no explicit hint is set.
97
+ #
98
+ # @param target [Symbol] e.g. :desktop, :mobile, :tty
99
+ # @return [WidgetHint, nil]
100
+ def effective_widget_hint_for(target: :desktop)
101
+ widget_hint_for(target:) || WidgetRegistry.default_hint_for(@type, context: target)
102
+ end
103
+
81
104
  # Resolves the next step id from current answers by evaluating transitions in order.
82
105
  #
83
106
  # @param answers [Hash] current answer state
@@ -115,6 +138,18 @@ module Inquirex
115
138
  end
116
139
 
117
140
  hash["transitions"] = @transitions.map(&:to_h) unless @transitions.empty?
141
+
142
+ if @widget_hints && !@widget_hints.empty?
143
+ hash["widget"] = @widget_hints.transform_keys(&:to_s)
144
+ .transform_values(&:to_h)
145
+ end
146
+
147
+ unless @accumulations.empty?
148
+ hash["accumulate"] = @accumulations.to_h do |acc|
149
+ [acc.target.to_s, acc.to_h]
150
+ end
151
+ end
152
+
118
153
  hash
119
154
  end
120
155
 
@@ -132,10 +167,14 @@ module Inquirex
132
167
  transitions_data = hash["transitions"] || hash[:transitions] || []
133
168
  skip_if_data = hash["skip_if"] || hash[:skip_if]
134
169
  default = hash["default"] || hash[:default]
170
+ widget_data = hash["widget"] || hash[:widget]
171
+ accumulate_data = hash["accumulate"] || hash[:accumulate]
135
172
 
136
173
  transitions = transitions_data.map { |t| Transition.from_h(t) }
137
174
  skip_if = skip_if_data ? Rules::Base.from_h(skip_if_data) : nil
138
175
  options = deserialize_options(raw_options)
176
+ widget_hints = deserialize_widget_hints(widget_data)
177
+ accumulations = deserialize_accumulations(accumulate_data)
139
178
 
140
179
  new(
141
180
  id:,
@@ -146,7 +185,9 @@ module Inquirex
146
185
  options:,
147
186
  transitions:,
148
187
  skip_if:,
149
- default:
188
+ default:,
189
+ widget_hints:,
190
+ accumulations:
150
191
  )
151
192
  end
152
193
 
@@ -159,7 +200,6 @@ module Inquirex
159
200
  @option_labels = raw.transform_keys(&:to_s).transform_values(&:to_s).freeze
160
201
  when Array
161
202
  if raw.first.is_a?(Hash)
162
- # Already in [{ "value" => ..., "label" => ... }] format
163
203
  @options = raw.map { |o| (o["value"] || o[:value]).to_s }.freeze
164
204
  @option_labels = raw.to_h { |o| [(o["value"] || o[:value]).to_s, (o["label"] || o[:label]).to_s] }.freeze
165
205
  else
@@ -188,5 +228,21 @@ module Inquirex
188
228
  raw.to_h { |o| [(o["value"] || o[:value]).to_s, (o["label"] || o[:label]).to_s] }
189
229
  end
190
230
  private_class_method :deserialize_options
231
+
232
+ def self.deserialize_accumulations(data)
233
+ return [] unless data.is_a?(Hash)
234
+
235
+ data.map { |target, entry| Accumulation.from_h(target.to_sym, entry) }
236
+ end
237
+ private_class_method :deserialize_accumulations
238
+
239
+ def self.deserialize_widget_hints(widget_data)
240
+ return nil unless widget_data.is_a?(Hash) && widget_data.any? { |_, v| v.is_a?(Hash) }
241
+
242
+ widget_data.each_with_object({}) do |(target, hint_hash), acc|
243
+ acc[target.to_sym] = WidgetHint.from_h(hint_hash)
244
+ end
245
+ end
246
+ private_class_method :deserialize_widget_hints
191
247
  end
192
248
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Inquirex
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.0"
5
5
  end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Inquirex
4
+ # Immutable rendering hint attached to a step node.
5
+ # Carries a widget type (e.g. :radio_group) and an optional options hash
6
+ # (e.g. { columns: 2 }). Framework-agnostic — consumed by the JS widget,
7
+ # TTY adapter, or any other frontend renderer.
8
+ #
9
+ # @example
10
+ # hint = WidgetHint.new(type: :radio_group, options: { columns: 2 })
11
+ # hint.to_h # => { "type" => "radio_group", "columns" => 2 }
12
+ WidgetHint = Data.define(:type, :options) do
13
+ def initialize(type:, options: {})
14
+ super(type: type.to_sym, options: options.transform_keys(&:to_sym).freeze)
15
+ end
16
+
17
+ # Serializes to a flat hash: type key + option keys merged in.
18
+ #
19
+ # @return [Hash<String, Object>]
20
+ def to_h
21
+ { "type" => type.to_s }.merge(options.transform_keys(&:to_s))
22
+ end
23
+
24
+ # Deserializes from a plain hash (string or symbol keys).
25
+ #
26
+ # @param hash [Hash]
27
+ # @return [WidgetHint]
28
+ def self.from_h(hash)
29
+ type = hash["type"] || hash[:type]
30
+ options = hash.reject { |k, _| k.to_s == "type" }
31
+ .transform_keys(&:to_sym)
32
+ new(type:, options:)
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Inquirex
4
+ # Canonical mapping from Inquirex data types to default widget types per rendering context.
5
+ #
6
+ # Desktop defaults lean toward richer controls (radio groups, checkbox groups).
7
+ # Mobile defaults lean toward compact controls (dropdowns, toggles).
8
+ # TTY defaults align with tty-prompt methods.
9
+ #
10
+ # Adapters (TTY, JS, Rails) use this to pick an appropriate renderer when
11
+ # the DSL author has not specified an explicit `widget` hint.
12
+ module WidgetRegistry
13
+ WIDGET_TYPES = %i[
14
+ text_input
15
+ textarea
16
+ number_input
17
+ currency_input
18
+ toggle
19
+ yes_no_buttons
20
+ yes_no
21
+ radio_group
22
+ dropdown
23
+ checkbox_group
24
+ multi_select_dropdown
25
+ select
26
+ multi_select
27
+ enum_select
28
+ multiline
29
+ mask
30
+ slider
31
+ date_picker
32
+ email_input
33
+ phone_input
34
+ ].freeze
35
+
36
+ # Default widget per data type and rendering context.
37
+ DEFAULTS = {
38
+ string: { desktop: :text_input, mobile: :text_input, tty: :text_input },
39
+ text: { desktop: :textarea, mobile: :textarea, tty: :multiline },
40
+ integer: { desktop: :number_input, mobile: :number_input, tty: :number_input },
41
+ decimal: { desktop: :number_input, mobile: :number_input, tty: :number_input },
42
+ currency: { desktop: :currency_input, mobile: :currency_input, tty: :number_input },
43
+ boolean: { desktop: :toggle, mobile: :yes_no_buttons, tty: :yes_no },
44
+ enum: { desktop: :radio_group, mobile: :dropdown, tty: :select },
45
+ multi_enum: { desktop: :checkbox_group, mobile: :checkbox_group, tty: :multi_select },
46
+ date: { desktop: :date_picker, mobile: :date_picker, tty: :text_input },
47
+ email: { desktop: :email_input, mobile: :email_input, tty: :text_input },
48
+ phone: { desktop: :phone_input, mobile: :phone_input, tty: :text_input }
49
+ }.freeze
50
+
51
+ # Returns the default widget type for a given data type and context.
52
+ #
53
+ # @param type [Symbol, String, nil] Inquirex data type (e.g. :enum, :integer)
54
+ # @param context [Symbol] :desktop, :mobile, or :tty (default: :desktop)
55
+ # @return [Symbol, nil] widget type symbol, or nil if type is unknown
56
+ def self.default_for(type, context: :desktop)
57
+ DEFAULTS.dig(type&.to_sym, context)
58
+ end
59
+
60
+ # Returns a WidgetHint with the default widget for the given type + context,
61
+ # or nil when no default exists (e.g. for display verbs with no type).
62
+ #
63
+ # @param type [Symbol, String, nil]
64
+ # @param context [Symbol]
65
+ # @return [WidgetHint, nil]
66
+ def self.default_hint_for(type, context: :desktop)
67
+ widget_type = default_for(type, context:)
68
+ widget_type ? WidgetHint.new(type: widget_type) : nil
69
+ end
70
+ end
71
+ end
data/lib/inquirex.rb CHANGED
@@ -13,9 +13,14 @@ require_relative "inquirex/rules/not_empty"
13
13
  require_relative "inquirex/rules/all"
14
14
  require_relative "inquirex/rules/any"
15
15
 
16
+ # Widget hints
17
+ require_relative "inquirex/widget_hint"
18
+ require_relative "inquirex/widget_registry"
19
+
16
20
  # Core graph objects
17
21
  require_relative "inquirex/transition"
18
22
  require_relative "inquirex/evaluator"
23
+ require_relative "inquirex/accumulator"
19
24
  require_relative "inquirex/node"
20
25
  require_relative "inquirex/definition"
21
26
  require_relative "inquirex/answers"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: inquirex
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Konstantin Gredeskoul
@@ -32,10 +32,12 @@ files:
32
32
  - LICENSE.txt
33
33
  - README.md
34
34
  - Rakefile
35
+ - docs/badges/coverage_badge.svg
35
36
  - exe/inquirex
36
37
  - justfile
37
38
  - lefthook.yml
38
39
  - lib/inquirex.rb
40
+ - lib/inquirex/accumulator.rb
39
41
  - lib/inquirex/answers.rb
40
42
  - lib/inquirex/definition.rb
41
43
  - lib/inquirex/dsl.rb
@@ -60,15 +62,17 @@ files:
60
62
  - lib/inquirex/validation/adapter.rb
61
63
  - lib/inquirex/validation/null_adapter.rb
62
64
  - lib/inquirex/version.rb
65
+ - lib/inquirex/widget_hint.rb
66
+ - lib/inquirex/widget_registry.rb
63
67
  - sig/inquirex.rbs
64
- homepage: https://github.com/kigster/inquirex
68
+ homepage: https://github.com/inquirex/inquirex
65
69
  licenses:
66
70
  - MIT
67
71
  metadata:
68
72
  allowed_push_host: https://rubygems.org
69
- homepage_uri: https://github.com/kigster/inquirex
70
- source_code_uri: https://github.com/kigster/inquirex
71
- changelog_uri: https://github.com/kigster/inquirex/blob/main/CHANGELOG.md
73
+ homepage_uri: https://github.com/inquirex/inquirex
74
+ source_code_uri: https://github.com/inquirex/inquirex
75
+ changelog_uri: https://github.com/inquirex/inquirex/blob/main/CHANGELOG.md
72
76
  rubygems_mfa_required: 'true'
73
77
  rdoc_options: []
74
78
  require_paths: