@xeplr/ui-factory 1.0.11 → 1.0.13

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.
package/README.md CHANGED
@@ -178,6 +178,55 @@ import schemaHandler from '@xeplr/schema-handler' // CommonJS: default
178
178
  const clean = schemaHandler.applySchema(formSchema(screen), req.body) // throws ValidationError listing each field
179
179
  ```
180
180
 
181
+ ## Flows — screens, one after another
182
+
183
+ A screen is a form; a **flow** is the journey across several of them. Design it
184
+ the same way you design a form — screens on a canvas, arrows between them:
185
+
186
+ ```jsx
187
+ import { FlowBuilder, FlowRunner, createFlowsApi } from '@xeplr/ui-factory'
188
+ const flows = createFlowsApi({ fetch: authFetch, base: '/api' })
189
+
190
+ <FlowBuilder {...flows.builderProps} flow={flow} screens={published} loadScreen={factory.loadScreen} />
191
+ <FlowRunner {...flows.runnerProps} {...factory.screenProps} flowKey="employee_registration" runId={runId} />
192
+ ```
193
+
194
+ An arrow is a question asked once its screen is filled in — the first one that
195
+ fits wins, and the one with no test ("otherwise") catches the rest:
196
+
197
+ ```json
198
+ { "stepKey": "details", "screen": "employee_edit",
199
+ "transitions": [
200
+ { "when": { "field": "type", "op": "=", "value": "contractor" }, "target": "contract" },
201
+ { "when": null, "target": "payroll" }
202
+ ] }
203
+ ```
204
+
205
+ **The browser does not run the journey.** `createFlowsApi` talks to a service
206
+ that does — Xeplr Workflow — so a run remembers where it got to: a journey can
207
+ be left and picked up later, by someone else, on another day. Without such a
208
+ service there are no flows; the factory has no engine of its own, on purpose.
209
+
210
+ **Each screen still saves itself, into its own table.** What travels with the
211
+ run is only what the arrows need and the id of the row that was written
212
+ (`recordId`), so the data is exactly where it would be without a flow.
213
+
214
+ `validateFlow(flow, { screens })` is the same check on both sides: a screen
215
+ that exists, arrows that point somewhere, one "otherwise" at the end, and
216
+ nothing unreachable.
217
+
218
+ ### A flow and a stepper
219
+
220
+ Both walk someone through something, and they answer different questions:
221
+
222
+ | | stepper | flow |
223
+ |---|---|---|
224
+ | where | steps of ONE screen | several screens |
225
+ | when | one sitting | over days, if need be |
226
+ | who | one person | can change hands |
227
+ | decides | a `step` hook, in the browser | the engine, on the server |
228
+ | needs | nothing | a workflow service |
229
+
181
230
  ## Controls
182
231
 
183
232
  | type | value | validation |
@@ -235,6 +284,25 @@ class TaskHooks extends FactoryHooks {
235
284
  }
236
285
  ```
237
286
 
287
+ A screen with branches can **rule steps out** from the same hook — they stay on
288
+ the bar, struck through, and Next and Back pass over them:
289
+
290
+ ```js
291
+ step(ctx) {
292
+ if (ctx.from === 0 && ctx.values.kind === 'simple') {
293
+ ctx.disable(['pricing', 'approval']) // by key, label or number
294
+ return 'summary' // and go straight there
295
+ }
296
+ ctx.enable() // every step applies again
297
+ return super.step(ctx)
298
+ }
299
+ ```
300
+
301
+ `ctx.go`, `ctx.disable` and `ctx.enable` act on that stepper; `ctx.steps` is the
302
+ same for a screen with more than one (`ctx.steps.disable('approval', stepperId)`).
303
+ `ctx.steps` is on `save` and `get` too, since a save is usually where an app
304
+ learns which branch it is on. "Step 2 of 4" counts only the steps that apply.
305
+
238
306
  **A step is not a separate form.** Every field is a column, is checked, and is
239
307
  saved, whichever step is showing — a screen still saves itself as it is filled
240
308
  in. **Next** holds at a step whose own fields are not filled in, and says which,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xeplr/ui-factory",
3
- "version": "1.0.11",
3
+ "version": "1.0.13",
4
4
  "description": "Design data-entry screens on @xeplr/ui-canvas, save them as JSON, run them as auto-saving forms with lists of saved records — built for Claude to draft and people to refine",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -341,7 +341,8 @@ function StepperView({ node, p, design, stepper }) {
341
341
  <div className="xeplr-factory-stepper" style={{ ...fieldStyle(p.style), ...text }}>
342
342
  <ol className="xeplr-factory-steps">
343
343
  {steps.map((s, i) => {
344
- const state = i === active ? 'is-active' : i < active ? 'is-done' : ''
344
+ const off = Boolean(stepper && stepper.isDisabled && stepper.isDisabled(i))
345
+ const state = off ? 'is-off' : i === active ? 'is-active' : i < active ? 'is-done' : ''
345
346
  return (
346
347
  <li key={s.key} className={`xeplr-factory-step ${state}`} aria-current={i === active ? 'step' : undefined}>
347
348
  <button
@@ -349,7 +350,9 @@ function StepperView({ node, p, design, stepper }) {
349
350
  className="xeplr-factory-step-button"
350
351
  // On the canvas a press must still be able to drag the control,
351
352
  // so the step is taken on click, never on mousedown.
352
- onClick={(e) => { if (go) { e.stopPropagation(); go(i) } }}
353
+ onClick={(e) => { if (go && !off) { e.stopPropagation(); go(i) } }}
354
+ disabled={off}
355
+ aria-disabled={off || undefined}
353
356
  tabIndex={design ? -1 : undefined}
354
357
  >
355
358
  <span className="xeplr-factory-step-mark" aria-hidden="true">{i < active ? '✓' : (p.showNumbers === false ? '' : i + 1)}</span>
@@ -0,0 +1,274 @@
1
+ import { XeplrCanvas } from '@xeplr/ui-canvas'
2
+ import { FLOW_END, FLOW_OPERATORS, VALUELESS_OPERATORS, describeWhen } from '../flow.js'
3
+
4
+ // THE FLOW DESIGNER — screens on a canvas, arrows between them.
5
+ //
6
+ // A box is a screen. An arrow leaving it is a question asked once the screen
7
+ // is filled in: the first arrow whose test passes wins, and the one with no
8
+ // test — "otherwise" — catches the rest. That is the whole language, and it is
9
+ // the same one the engine runs.
10
+ //
11
+ // Presentation only: everything comes from useFlowBuilder.
12
+
13
+ const STEP_W = 200
14
+ const STEP_H = 86
15
+
16
+ const STATUS = {
17
+ saved: 'All changes saved',
18
+ pending: 'Unsaved changes…',
19
+ saving: 'Saving…',
20
+ invalid: 'Not saved — finish the flow first',
21
+ error: 'Not saved'
22
+ }
23
+
24
+ export default function FlowBuilderSample({ ctrl, className, style }) {
25
+ const { flow } = ctrl
26
+ const items = flow.steps.map((s) => ({
27
+ id: s.stepKey,
28
+ x: (s.layout && s.layout.x) || 40,
29
+ y: (s.layout && s.layout.y) || 40,
30
+ w: STEP_W,
31
+ h: STEP_H
32
+ }))
33
+ // One arrow per transition. A step that ends the journey gets a stub to a
34
+ // "Finished" pill, the way the workflow editor draws its ends.
35
+ const edges = flow.steps.flatMap((s) => s.transitions.map((t, i) => ({
36
+ id: `${s.stepKey}-${i}`,
37
+ from: s.stepKey,
38
+ ...(t.target === FLOW_END || !flow.steps.some((x) => x.stepKey === t.target)
39
+ ? { toOffset: { x: 90, y: 0 }, done: true }
40
+ : { to: t.target }),
41
+ variant: t.when ? undefined : 'dashed',
42
+ label: describeWhen(t.when, labelOf(ctrl, s.stepKey, t.when))
43
+ })))
44
+
45
+ return (
46
+ <div className={'xeplr-factory-flow-builder' + (className ? ' ' + className : '')} style={style}>
47
+ <header className="xeplr-factory-bar">
48
+ <input
49
+ className="xeplr-factory-name"
50
+ value={flow.name}
51
+ onChange={(e) => ctrl.rename(e.target.value)}
52
+ placeholder="What is this journey called?"
53
+ aria-label="Flow name"
54
+ />
55
+ <code className="xeplr-factory-flow-key" title="The key this flow is known by — it never changes">{flow.key}</code>
56
+ <div className={`xeplr-factory-bar-status xeplr-factory-bar-status--${ctrl.status}`} role="status" aria-live="polite">
57
+ {ctrl.status === 'error' ? `Not saved — ${ctrl.saveError}` : STATUS[ctrl.status]}
58
+ </div>
59
+ {ctrl.validation.errors.length > 0 && (
60
+ <span className="xeplr-factory-problems" title={ctrl.validation.errors.map((x) => `${x.path}: ${x.message}`).join('\n')}>
61
+ {ctrl.validation.errors.length} problem{ctrl.validation.errors.length === 1 ? '' : 's'}
62
+ </span>
63
+ )}
64
+ {flow.steps.length > 1 && (
65
+ <button type="button" className="xeplr-factory-secondary" onClick={ctrl.tidy} title="Lay the screens out as the journey reads">Tidy</button>
66
+ )}
67
+ {ctrl.canPublish && (
68
+ <button type="button" className="xeplr-factory-primary" onClick={ctrl.publish} disabled={ctrl.publishing || !ctrl.validation.ok}>
69
+ {ctrl.publishing ? 'Publishing…' : 'Publish'}
70
+ </button>
71
+ )}
72
+ </header>
73
+
74
+ {ctrl.publishResult && (
75
+ <div className={`xeplr-factory-publish${ctrl.publishResult.ok ? ' is-ok' : ' is-error'}`} role="status">
76
+ <span>{ctrl.publishResult.message}</span>
77
+ <button type="button" className="xeplr-factory-icon-button" aria-label="Dismiss" onClick={ctrl.clearPublishResult}>×</button>
78
+ </div>
79
+ )}
80
+
81
+ <div className="xeplr-factory-body">
82
+ <aside className="xeplr-factory-palette" aria-label="Screens">
83
+ <div className="xeplr-factory-palette-group">
84
+ <h3 className="xeplr-factory-palette-title">Screens</h3>
85
+ {ctrl.screens.length === 0 && <p className="xeplr-factory-hint">No published forms yet.</p>}
86
+ {ctrl.screens.map((s) => (
87
+ <button
88
+ key={s.id}
89
+ type="button"
90
+ className="xeplr-factory-palette-item"
91
+ onClick={() => ctrl.addStep(s.id, nextPlace(flow))}
92
+ title={`Add ${s.name || s.id} to the journey`}
93
+ >
94
+ {s.name || s.id}
95
+ </button>
96
+ ))}
97
+ </div>
98
+ </aside>
99
+
100
+ <div className="xeplr-factory-stage">
101
+ <XeplrCanvas
102
+ className="xeplr-factory-flow-canvas"
103
+ items={items}
104
+ edges={edges}
105
+ features={{ drag: true, snap: true }}
106
+ selection={ctrl.selected ? [ctrl.selected] : []}
107
+ onSelectionChange={(ids) => ctrl.setSelected([...ids][0] || null)}
108
+ onItemChange={(id, patch) => ctrl.moveStep(id, patch)}
109
+ onItemClick={(id) => ctrl.setSelected(id)}
110
+ renderItem={(item) => {
111
+ const step = flow.steps.find((s) => s.stepKey === item.id)
112
+ if (!step) return null
113
+ const problems = ctrl.errorsByStep[step.stepKey] || []
114
+ return (
115
+ <div className={`xeplr-factory-step-card${ctrl.selected === step.stepKey ? ' is-selected' : ''}${problems.length ? ' has-error' : ''}`}>
116
+ <span className="xeplr-factory-step-card-name">{step.label || step.stepKey}</span>
117
+ <span className="xeplr-factory-step-card-screen">{screenName(ctrl, step.screen)}</span>
118
+ {ctrl.first && ctrl.first.stepKey === step.stepKey && <span className="xeplr-factory-step-card-first">starts here</span>}
119
+ </div>
120
+ )
121
+ }}
122
+ renderEdgeLabel={(edge, { mid }) => (
123
+ <span className="xeplr-factory-edge-label" style={{ position: 'absolute', left: mid.x, top: mid.y, transform: 'translate(-50%, -50%)' }}>
124
+ {edge.done ? 'Finished' : edge.label}
125
+ </span>
126
+ )}
127
+ underlay={flow.steps.length === 0 && (
128
+ <div className="xeplr-factory-empty">Add a screen from the left to start the journey</div>
129
+ )}
130
+ />
131
+ </div>
132
+
133
+ <StepPanel ctrl={ctrl} />
134
+ </div>
135
+ </div>
136
+ )
137
+ }
138
+
139
+ /** What a step does, and where it goes next. */
140
+ function StepPanel({ ctrl }) {
141
+ const step = ctrl.selectedStep
142
+ if (!step) {
143
+ return (
144
+ <aside className="xeplr-factory-panel" aria-label="Step">
145
+ <p className="xeplr-factory-panel-empty">
146
+ Pick a screen on the canvas to say what happens after it.
147
+ <br /><br />
148
+ Each arrow is a question asked once that screen is filled in — the first one that fits wins, and “otherwise” catches the rest.
149
+ </p>
150
+ </aside>
151
+ )
152
+ }
153
+ const fields = ctrl.fieldsFor(step.stepKey)
154
+ const others = ctrl.flow.steps.filter((s) => s.stepKey !== step.stepKey)
155
+
156
+ return (
157
+ <aside className="xeplr-factory-panel" aria-label="Step">
158
+ <div className="xeplr-factory-panel-head">
159
+ <span className="xeplr-factory-panel-type">{step.label || step.stepKey}</span>
160
+ <button type="button" className="xeplr-factory-danger-link" onClick={() => ctrl.removeStep(step.stepKey)}>Remove</button>
161
+ </div>
162
+
163
+ <fieldset className="xeplr-factory-group">
164
+ <legend>Step</legend>
165
+ <div className="xeplr-factory-prop">
166
+ <label className="xeplr-factory-prop-label" htmlFor="xf-step-label">Name</label>
167
+ <input id="xf-step-label" className="xeplr-factory-prop-input" value={step.label || ''} onChange={(e) => ctrl.setStep(step.stepKey, { label: e.target.value })} />
168
+ </div>
169
+ <div className="xeplr-factory-prop">
170
+ <label className="xeplr-factory-prop-label" htmlFor="xf-step-screen">Shows</label>
171
+ <select id="xf-step-screen" className="xeplr-factory-prop-input" value={step.screen} onChange={(e) => ctrl.setStep(step.stepKey, { screen: e.target.value })}>
172
+ {ctrl.screens.map((s) => <option key={s.id} value={s.id}>{s.name || s.id}</option>)}
173
+ </select>
174
+ </div>
175
+ </fieldset>
176
+
177
+ <fieldset className="xeplr-factory-group">
178
+ <legend>Then</legend>
179
+ {step.transitions.length === 0 && <p className="xeplr-factory-hint">The journey ends here.</p>}
180
+ {step.transitions.map((t, i) => (
181
+ <div key={i} className="xeplr-factory-arrow">
182
+ {t.when === null ? (
183
+ <span className="xeplr-factory-arrow-when">Otherwise</span>
184
+ ) : (
185
+ <div className="xeplr-factory-arrow-test">
186
+ <select
187
+ className="xeplr-factory-prop-input"
188
+ value={t.when.field || ''}
189
+ onChange={(e) => ctrl.setTransition(step.stepKey, i, { when: { ...t.when, field: e.target.value } })}
190
+ aria-label="Field"
191
+ >
192
+ <option value="">Which field…</option>
193
+ {fields.map((f) => <option key={f.name} value={f.name}>{f.label}</option>)}
194
+ </select>
195
+ <select
196
+ className="xeplr-factory-prop-input"
197
+ value={t.when.op}
198
+ onChange={(e) => ctrl.setTransition(step.stepKey, i, { when: { ...t.when, op: e.target.value } })}
199
+ aria-label="Test"
200
+ >
201
+ {FLOW_OPERATORS.map((o) => <option key={o.op} value={o.op}>{o.label}</option>)}
202
+ </select>
203
+ {!VALUELESS_OPERATORS.includes(t.when.op) && (
204
+ <input
205
+ className="xeplr-factory-prop-input"
206
+ value={t.when.value ?? ''}
207
+ placeholder="value"
208
+ onChange={(e) => ctrl.setTransition(step.stepKey, i, { when: { ...t.when, value: e.target.value } })}
209
+ aria-label="Value"
210
+ />
211
+ )}
212
+ </div>
213
+ )}
214
+ <div className="xeplr-factory-arrow-target">
215
+ <span>go to</span>
216
+ <select
217
+ className="xeplr-factory-prop-input"
218
+ value={t.target}
219
+ onChange={(e) => ctrl.setTransition(step.stepKey, i, { target: e.target.value })}
220
+ aria-label="Goes to"
221
+ >
222
+ {others.map((s) => <option key={s.stepKey} value={s.stepKey}>{s.label || s.stepKey}</option>)}
223
+ <option value={FLOW_END}>Finish</option>
224
+ </select>
225
+ <button type="button" className="xeplr-factory-icon-button" aria-label="Remove this arrow" onClick={() => ctrl.disconnect(step.stepKey, i)}>×</button>
226
+ </div>
227
+ </div>
228
+ ))}
229
+ <div className="xeplr-factory-arrow-add">
230
+ <button
231
+ type="button"
232
+ className="xeplr-factory-add"
233
+ onClick={() => ctrl.connect(step.stepKey, others.length ? others[0].stepKey : FLOW_END, { field: fields[0] ? fields[0].name : '', op: '=', value: '' })}
234
+ >
235
+ + If a field says…
236
+ </button>
237
+ {!step.transitions.some((t) => t.when === null) && (
238
+ <button
239
+ type="button"
240
+ className="xeplr-factory-add"
241
+ onClick={() => ctrl.connect(step.stepKey, others.length ? others[0].stepKey : FLOW_END, null)}
242
+ >
243
+ + Otherwise
244
+ </button>
245
+ )}
246
+ </div>
247
+ </fieldset>
248
+
249
+ {(ctrl.errorsByStep[step.stepKey] || []).length > 0 && (
250
+ <div className="xeplr-factory-panel-errors" role="alert">
251
+ {ctrl.errorsByStep[step.stepKey].map((e, i) => <div key={i}>{e.message}</div>)}
252
+ </div>
253
+ )}
254
+ </aside>
255
+ )
256
+ }
257
+
258
+ /** Where the next screen goes: to the right of the last one, a row lower after four. */
259
+ function nextPlace(flow) {
260
+ const n = flow.steps.length
261
+ return { x: 60 + (n % 4) * (STEP_W + 90), y: 80 + Math.floor(n / 4) * (STEP_H + 110) }
262
+ }
263
+
264
+ function screenName(ctrl, id) {
265
+ const hit = ctrl.screens.find((s) => s.id === id)
266
+ return (hit && hit.name) || id
267
+ }
268
+
269
+ /** A field's label, for an arrow that tests it — the person never sees column names. */
270
+ function labelOf(ctrl, stepKey, when) {
271
+ if (!when) return null
272
+ const hit = ctrl.fieldsFor(stepKey).find((f) => f.name === when.field)
273
+ return hit ? hit.label : when.field
274
+ }
@@ -0,0 +1,69 @@
1
+ import { useRef } from 'react'
2
+ import ScreenSample from './ScreenSample.jsx'
3
+
4
+ // A flow, running: the screen the run is parked on, and one button forward.
5
+ //
6
+ // Presentation only — everything comes from useFlowRun and useFactoryScreen.
7
+ // The screen saves itself into its own table exactly as it does anywhere else;
8
+ // "Next" hands the run what was filled in, and the run says which screen comes
9
+ // after it. Nothing here knows the journey.
10
+
11
+ export default function FlowRunnerSample({ run, screen, className, style, title, renderDone }) {
12
+ const values = useRef({})
13
+ const record = useRef(null)
14
+
15
+ if (run.loading) return <div className="xeplr-factory-flow-loading">Opening…</div>
16
+ if (run.error) {
17
+ return (
18
+ <div className="xeplr-factory-invalid" role="alert">
19
+ <strong>This journey cannot go on:</strong> {run.error}
20
+ </div>
21
+ )
22
+ }
23
+ if (run.status === 'done') {
24
+ return (
25
+ <div className={'xeplr-factory-flow' + (className ? ' ' + className : '')} style={style}>
26
+ {renderDone
27
+ ? renderDone(run)
28
+ : <div className="xeplr-factory-flow-done" role="status"><strong>All done.</strong> Nothing more to fill in.</div>}
29
+ </div>
30
+ )
31
+ }
32
+ if (!run.document) return null
33
+
34
+ return (
35
+ <div className={'xeplr-factory-flow' + (className ? ' ' + className : '')} style={style}>
36
+ {title && (
37
+ <div className="xeplr-factory-flow-head">
38
+ <span className="xeplr-factory-flow-where">{title}</span>
39
+ {run.sending && <span className="xeplr-factory-flow-sending">Working out what comes next…</span>}
40
+ </div>
41
+ )}
42
+ <ScreenSample
43
+ // A new step is a new form: the key stops the screen keeping the last
44
+ // one's values while the document changes under it.
45
+ key={run.stepKey}
46
+ ctrl={{
47
+ ...screen,
48
+ // The flow's forward button IS the screen's own, renamed: one button,
49
+ // not two that both look like the way on.
50
+ done: () => run.submit(values.current, record.current),
51
+ doneLabel: run.sending ? 'Saving…' : 'Next'
52
+ }}
53
+ />
54
+ <FlowValues screen={screen} values={values} record={record} />
55
+ </div>
56
+ )
57
+ }
58
+
59
+ /**
60
+ * The bridge between the screen and the run: what is filled in, and the row
61
+ * the screen saved. Kept in refs — the run reads them when the person moves
62
+ * on, and a re-render on every keystroke would be the form's business, not
63
+ * the journey's.
64
+ */
65
+ function FlowValues({ screen, values, record }) {
66
+ values.current = screen.values
67
+ record.current = screen.recordId
68
+ return null
69
+ }
@@ -29,17 +29,26 @@ const STATUS = {
29
29
  */
30
30
  function StepNav({ ctrl, node, children }) {
31
31
  const at = ctrl.steps.active(node.id)
32
- const last = ctrl.steps.count(node.id) - 1
32
+ const total = ctrl.steps.count(node.id)
33
+ const off = ctrl.steps.disabled(node.id)
33
34
  const unfinished = ctrl.steps.fieldsOn(node.id, at).filter((n) => ctrl.allErrors[n.props.name])
35
+ // Back and Next go to the nearest step that is switched on, passing over any
36
+ // the app has ruled out; null means there is none that way.
37
+ const back = ctrl.steps.nextOpen(node.id, 'back')
38
+ const forward = ctrl.steps.nextOpen(node.id, 'next')
34
39
  const go = (to, direction) => {
35
- if (to > at && unfinished.length) { unfinished.forEach((n) => ctrl.touch(n)); return }
36
- ctrl.steps.go(node.id, Math.min(Math.max(to, 0), last), direction)
40
+ if (to === null) return
41
+ if (direction === 'next' && unfinished.length) { unfinished.forEach((n) => ctrl.touch(n)); return }
42
+ ctrl.steps.go(node.id, to, direction)
37
43
  }
44
+ // "Step 2 of 4" counts the steps that apply, not the ones ruled out.
45
+ const live = total - off.length
46
+ const place = at - off.filter((i) => i < at).length + 1
38
47
  return (
39
48
  <div className="xeplr-factory-stepnav">
40
- <button type="button" className="xeplr-factory-secondary" onClick={() => go(at - 1, 'back')} disabled={at === 0}>Back</button>
41
- <span className="xeplr-factory-stepnav-where">Step {at + 1} of {last + 1}</span>
42
- <button type="button" className="xeplr-factory-primary" onClick={() => go(at + 1, 'next')} disabled={at === last}>Next</button>
49
+ <button type="button" className="xeplr-factory-secondary" onClick={() => go(back, 'back')} disabled={back === null}>Back</button>
50
+ <span className="xeplr-factory-stepnav-where">Step {place} of {live}</span>
51
+ <button type="button" className="xeplr-factory-primary" onClick={() => go(forward, 'next')} disabled={forward === null}>Next</button>
43
52
  {children}
44
53
  </div>
45
54
  )
@@ -49,8 +58,13 @@ function StepNav({ ctrl, node, children }) {
49
58
  function DoneButton({ ctrl }) {
50
59
  const saving = ctrl.status === 'pending' || ctrl.status === 'saving'
51
60
  return (
52
- <button type="button" className="xeplr-factory-secondary" onClick={() => ctrl.flush().then(ctrl.done, ctrl.done)}>
53
- {saving ? 'Saving…' : 'Done'}
61
+ <button
62
+ type="button"
63
+ className={ctrl.doneLabel ? 'xeplr-factory-primary' : 'xeplr-factory-secondary'}
64
+ disabled={!ctrl.canSave && ctrl.status === 'invalid'}
65
+ onClick={() => ctrl.flush().then(ctrl.done, ctrl.done)}
66
+ >
67
+ {saving ? 'Saving…' : (ctrl.doneLabel || 'Done')}
54
68
  </button>
55
69
  )
56
70
  }
@@ -116,7 +130,11 @@ export default function ScreenSample({ ctrl, className, style, renderPopup }) {
116
130
  error={node.props?.name ? ctrl.errors[node.props.name] : undefined}
117
131
  options={CHOICE_TYPES.includes(node.type) ? ctrl.optionsFor(node) : undefined}
118
132
  upload={node.type === 'file' ? ctrl.upload : undefined}
119
- stepper={node.type === 'stepper' ? { active: ctrl.steps.active(node.id), onStep: (i) => ctrl.steps.go(node.id, i, 'jump') } : undefined}
133
+ stepper={node.type === 'stepper' ? {
134
+ active: ctrl.steps.active(node.id),
135
+ isDisabled: (i) => ctrl.steps.isDisabled(node.id, i),
136
+ onStep: (i) => ctrl.steps.go(node.id, i, 'jump')
137
+ } : undefined}
120
138
  onChange={(raw) => ctrl.setValue(node, raw)}
121
139
  onBlur={() => ctrl.touch(node)}
122
140
  list={node.type === 'list' ? {
@@ -8,7 +8,8 @@
8
8
  it shrinks, so the layout keeps its proportions. */
9
9
 
10
10
  .xeplr-factory-screen,
11
- .xeplr-factory-canvas {
11
+ .xeplr-factory-canvas,
12
+ .xeplr-factory-flow-builder {
12
13
  container-type: inline-size;
13
14
  --xf-u: min(1px, calc(100cqw / var(--xf-width, 800)));
14
15
  --xf-accent: var(--xeplr-accent, #2563eb);
@@ -129,6 +130,10 @@
129
130
  .xeplr-factory-step.is-active .xeplr-factory-step-mark,
130
131
  .xeplr-factory-step.is-done .xeplr-factory-step-mark { background: var(--xf-accent); color: #fff; }
131
132
  .xeplr-factory-step.is-active .xeplr-factory-step-button { color: var(--xf-accent); font-weight: 600; }
133
+ /* A step the app has ruled out: still shown, so the journey reads the same,
134
+ but plainly not one of the stops. */
135
+ .xeplr-factory-step.is-off .xeplr-factory-step-button { opacity: 0.4; cursor: default; }
136
+ .xeplr-factory-step.is-off .xeplr-factory-step-label { text-decoration: line-through; }
132
137
 
133
138
  /* Back / Next, under the page. */
134
139
  .xeplr-factory-stepnav { display: flex; align-items: center; gap: 12px; padding: 10px 0; }
@@ -314,3 +319,50 @@
314
319
  .xeplr-factory-confirm-text { flex: 1; line-height: 1.5; }
315
320
  .xeplr-factory-confirm-text ul { margin: 4px 0; padding-left: 18px; }
316
321
  .xeplr-factory-confirm-actions { display: flex; gap: 8px; flex-shrink: 0; }
322
+
323
+ /* ── flows: screens joined by arrows ──────────────────────────────────────
324
+ The designer wears the builder's chrome (bar, palette, panel); what is new
325
+ is the step card on the canvas, the label on an arrow, and the rows that
326
+ say where a step goes next. */
327
+ .xeplr-factory-flow-builder { display: flex; flex-direction: column; height: 100%; min-height: 0; color: var(--xeplr-text-primary, #1a1a19); }
328
+ .xeplr-factory-flow-key { margin-right: auto; padding: 2px 6px; border-radius: 4px; background: var(--xf-border); font-size: 11px; }
329
+ .xeplr-factory-flow-canvas { height: 100%; }
330
+
331
+ .xeplr-factory-step-card {
332
+ display: flex; flex-direction: column; justify-content: center; gap: 2px;
333
+ width: 100%; height: 100%; box-sizing: border-box; padding: 10px 12px;
334
+ border: 1px solid var(--xf-border); border-radius: 10px; background: var(--xf-surface);
335
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
336
+ }
337
+ .xeplr-factory-step-card.is-selected { border-color: var(--xf-accent); box-shadow: 0 0 0 2px color-mix(in srgb, var(--xf-accent) 30%, transparent); }
338
+ .xeplr-factory-step-card.has-error { border-color: var(--xf-danger); }
339
+ .xeplr-factory-step-card-name { font-weight: 640; color: var(--xf-ink); }
340
+ .xeplr-factory-step-card-screen { color: var(--xf-muted); font-size: 12px; }
341
+ .xeplr-factory-step-card-first { color: var(--xf-accent); font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; }
342
+
343
+ .xeplr-factory-edge-label {
344
+ /* Above the curve's midpoint rather than on it, so a label never sits on the
345
+ card an arrow is arriving at. */
346
+ margin-top: -12px;
347
+ padding: 1px 6px; border-radius: 999px; background: var(--xf-surface);
348
+ border: 1px solid var(--xf-border); color: var(--xf-muted); font-size: 11px; white-space: nowrap;
349
+ }
350
+
351
+ .xeplr-factory-arrow { padding: 8px 0; border-top: 1px dashed var(--xf-border); }
352
+ .xeplr-factory-arrow:first-of-type { border-top: none; }
353
+ .xeplr-factory-arrow-when { font-weight: 600; font-size: 12px; }
354
+ .xeplr-factory-arrow-test { display: grid; gap: 4px; }
355
+ .xeplr-factory-arrow-target { display: flex; align-items: center; gap: 6px; margin-top: 6px; color: var(--xf-muted); font-size: 12px; }
356
+ .xeplr-factory-arrow-target select { flex: 1; }
357
+ .xeplr-factory-arrow-add { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 8px; }
358
+ .xeplr-factory-danger-link { border: none; background: none; color: var(--xf-danger); font: inherit; cursor: pointer; padding: 0; }
359
+
360
+ /* ── a flow, running ─────────────────────────────────────────────────────── */
361
+ .xeplr-factory-flow { display: flex; flex-direction: column; gap: 8px; }
362
+ .xeplr-factory-flow-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
363
+ .xeplr-factory-flow-where { font-weight: 640; }
364
+ .xeplr-factory-flow-sending, .xeplr-factory-flow-loading { color: var(--xf-muted); font-size: 12px; }
365
+ .xeplr-factory-flow-done {
366
+ padding: 16px; border-radius: 8px; border: 1px solid var(--xf-border);
367
+ background: var(--xf-surface); color: var(--xf-ink);
368
+ }
@@ -4,6 +4,8 @@ export { default as BuilderSample } from './BuilderSample.jsx'
4
4
  export { default as ScreenSample } from './ScreenSample.jsx'
5
5
  export { default as ListView } from './ListView.jsx'
6
6
  export { default as ScreenModal } from './ScreenModal.jsx'
7
+ export { default as FlowRunnerSample } from './FlowRunnerSample.jsx'
8
+ export { default as FlowBuilderSample } from './FlowBuilderSample.jsx'
7
9
  export { default as ControlView, VIEWS } from './ControlView.jsx'
8
10
  export { default as Palette, DRAG_TYPE } from './Palette.jsx'
9
11
  export { default as PropertyPanel, EDITORS } from './PropertyPanel.jsx'
package/src/document.js CHANGED
@@ -155,6 +155,46 @@ export function nodesForSteps(doc, active = {}, controls = CONTROLS) {
155
155
  return (doc.nodes || []).filter((n) => shown(n, new Set()))
156
156
  }
157
157
 
158
+ // Which step a move lands on. Pure, and the only place the rules live: a step
159
+ // may be named by its key, its label or its number; a step that is switched
160
+ // off is passed over, in the direction of travel, and only when there is
161
+ // nothing that way is the other direction tried.
162
+
163
+ /** A step named by key, label or number → its index, or -1. */
164
+ export function resolveStep(steps, which) {
165
+ if (Number.isInteger(which)) return which >= 0 && which < steps.length ? which : -1
166
+ return steps.findIndex((s) => s.key === which || s.label === which)
167
+ }
168
+
169
+ /**
170
+ * The step actually reached when aiming at `index`.
171
+ * @param disabled indexes switched off
172
+ * @param direction 'next' | 'back' | 'jump'
173
+ * @param from where we are now, for when nothing is open either way
174
+ */
175
+ export function reachableStep(count, index, disabled = [], direction = 'next', from = 0) {
176
+ if (count <= 0) return 0
177
+ const off = (i) => disabled.includes(i)
178
+ const clamp = (i) => Math.min(Math.max(i, 0), count - 1)
179
+ const way = direction === 'back' ? -1 : 1
180
+ let i = clamp(index)
181
+ while (i >= 0 && i < count && off(i)) i += way
182
+ if (i < 0 || i >= count) {
183
+ i = clamp(index)
184
+ while (i >= 0 && i < count && off(i)) i -= way
185
+ }
186
+ return i >= 0 && i < count ? i : clamp(from)
187
+ }
188
+
189
+ /** The nearest open step in this direction, or null when there is none. */
190
+ export function nextOpenStep(count, at, disabled = [], direction = 'next') {
191
+ const step = direction === 'back' ? -1 : 1
192
+ const aim = at + step
193
+ if (aim < 0 || aim >= count) return null
194
+ const i = reachableStep(count, aim, disabled, direction, at)
195
+ return i === at || disabled.includes(i) ? null : i
196
+ }
197
+
158
198
  /** Merges a geometry patch — what the canvas reports on drop. */
159
199
  export function moveNode(doc, id, patch) {
160
200
  const geometry = {}