@xeplr/ui-factory 1.0.5 → 1.0.7

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/AUTHORING.md CHANGED
@@ -168,15 +168,20 @@ Each field:
168
168
  | key | meaning |
169
169
  |---|---|
170
170
  | `label` | **required** for inputs — shown above the field |
171
- | `type` | `text` (default), `textarea`, `number`, `date`, `checkbox`, `dropdown`, or `label` for a section heading |
171
+ | `type` | `text` (default), `textarea`, `number`, `date`, `datetime`, `checkbox`, `dropdown`, `radio`, `multiselect`, `file`, `stepper`, or `label` for a section heading |
172
172
  | `name` | the key the value is saved under; defaults to the label in camelCase (`"Date of birth"` → `dateOfBirth`) |
173
173
  | `required` | `true` to require a value (a checkbox must be ticked) |
174
174
  | `placeholder` | text, textarea, number, dropdown |
175
175
  | `default` | starting value — a string, a number, `true`/`false`, or a date as `"YYYY-MM-DD"` |
176
176
  | `validation` | see below |
177
177
  | `width` | `"full"` to take the whole row in a two-column form (textareas and labels always do) |
178
- | `options` | dropdown: `["Full time", "Part time"]` (ids are made from the names: `full_time`) or `[{ "id": "ft", "name": "Full time" }]` |
179
- | `table` | dropdown: read options from this table in the app's database |
178
+ | `options` | dropdown, radio, multiselect: `["Full time", "Part time"]` (ids are made from the names: `full_time`) or `[{ "id": "ft", "name": "Full time" }]` |
179
+ | `table` | dropdown, radio, multiselect: read options from this table in the app's database |
180
+ | `layout` | radio, multiselect: `"vertical"` (default) or `"horizontal"` |
181
+ | `accept` | file: the extensions it takes, e.g. `".pdf,.docx"` |
182
+ | `steps` | stepper: `["Connect", "Transform", "Review"]` — at least two |
183
+ | `step` | which step this field is on: the step's label, or its number from 1. Needs a `stepper` earlier in `fields`; without a `step` a field shows on every step |
184
+ | `maxSize` | file: the largest file in megabytes (default 10) |
180
185
  | `text`, `variant` | `label` only — `variant` is `heading`, `subheading` (default for a section) or `text` |
181
186
  | `style` | how it looks — see Styles |
182
187
 
@@ -188,7 +193,9 @@ Each field:
188
193
  | `textarea` | `minLength`, `maxLength` |
189
194
  | `number` | `min`, `max`, `integer` |
190
195
  | `date` | `min`, `max` — as `"YYYY-MM-DD"` |
191
- | `checkbox`, `dropdown` | none (use `required`) |
196
+ | `datetime` | `min`, `max` — as `"YYYY-MM-DDTHH:MM"` |
197
+ | `multiselect` | `minItems`, `maxItems` — how many may be chosen |
198
+ | `checkbox`, `dropdown`, `radio`, `file` | none (use `required`) |
192
199
 
193
200
  Anything else is rejected, with the list of what is allowed.
194
201
 
@@ -236,10 +243,14 @@ A dropdown **always saves an `id` and shows a `name`.**
236
243
  | notes, description, address, comments | `textarea` |
237
244
  | amount, salary, quantity, age | `number` (`integer: true` for counts) |
238
245
  | a date — birth, start, due | `date` |
246
+ | an appointment, a deadline with a time, when something happened | `datetime` |
239
247
  | yes/no, agree, active, remote | `checkbox` |
240
- | one of a known set | `dropdown` with `options` |
248
+ | one of a known set | `dropdown` with `options` — a `radio` group when there are two or three and they should all be visible |
241
249
  | one of the app's records | `dropdown` with `table` |
250
+ | several of a set — tags, skills, days | `multiselect` |
251
+ | a document, a contract, a spreadsheet, a photo | `file` with `accept` (`".pdf,.docx"`) |
242
252
  | a section title between groups | `{ "type": "label", "text": "…", "variant": "subheading" }` |
253
+ | a long form people work through in stages | a `stepper`, then `step` on each field |
243
254
 
244
255
  Keep labels short and in sentence case ("Start date", not "START DATE:"). Mark only what the request needs as required.
245
256
 
package/README.md CHANGED
@@ -15,11 +15,16 @@ It is built to work with Claude: ask for *"a form for employees with name, email
15
15
 
16
16
  ```sh
17
17
  npx xeplr-factory screens employee.entity.json -o src/screens/employee
18
- # employee-list.screen.json EmployeeList.jsx
19
- # employee-edit.screen.json EditEmployee.jsx
20
- # employee.hooks.js server hooks for its records: save / get / delete × before / after / error / override
18
+ # employee-list.screen.json EmployeeList.jsx the list page — uses EditEmployee.jsx's hooks
19
+ # employee-edit.screen.json EditEmployee.jsx the form page, holding the FRONT-END hooks (EmployeeHooks extends FactoryHooks)
20
+ # employee.hooks.js SERVER hooks: save / get / delete × before / after / error / override
21
+ # employee.model.js SERVER model: EmployeeModel extends FactoryModel — getters / setters for values
21
22
  ```
22
23
 
24
+ `--no-pages` leaves out the two `.jsx` (for an app whose pages live elsewhere). `--force` replaces only the screen JSON: the pages, hooks and model are your code and are always kept.
25
+
26
+ **Rules:** records live in real tables, never JSON · a form's **key** (`employee`) names its screens and table and never changes once published; its **label** (the screens' name) is renamed any time · rules that must hold go in server hooks, never front-end hooks.
27
+
23
28
  See [examples/](./examples) for exactly what it writes.
24
29
 
25
30
  (The package name on npm is `@xeplr/ui-factory` — the GitHub repo and folder are named `xeplr-ui-factory`.)
@@ -82,6 +87,7 @@ import { FactoryScreen } from '@xeplr/ui-factory'
82
87
  fetchRecord={async ({ screen, id }) => api.record(screen, id)} // optional: Edit loads the record fresh
83
88
  onDelete={async ({ id, source }) => api.remove(source, id)}
84
89
  fetchOptions={async ({ table }) => api.options(table)}
90
+ uploadFile={async (file, { screen, field }) => api.upload(file, screen, field)} // a file field: → { path }
85
91
  screens={{ employee_edit: editScreen }} // what a list's Edit / New open in a popup
86
92
  />
87
93
  ```
@@ -90,7 +96,23 @@ import { FactoryScreen } from '@xeplr/ui-factory'
90
96
 
91
97
  If `onSave` throws an error with `fields: [{ field, message }]` (what `createFactoryApi` does with the server's 422 — a rule, or a hook's `reject`), each message shows on its field until that field is changed.
92
98
 
93
- Values arrive typed: numbers as numbers, checkboxes as booleans, dates as `"YYYY-MM-DD"`, and a dropdown as the option's own `id` (a table's numeric id stays a number).
99
+ Values arrive typed: numbers as numbers, checkboxes as booleans, dates as `"YYYY-MM-DD"`, a date and time as `"YYYY-MM-DDTHH:MM"`, a dropdown or radio group as the option's own `id` (a table's numeric id stays a number), a multi-select as an **array** of ids, and a file as the stored file's **path**.
100
+
101
+ ### Where each value is kept
102
+
103
+ A record is columns in a real table, never JSON — so the two types that are not
104
+ one scalar have a rule of their own:
105
+
106
+ * **Multi-select** — every chosen id in ONE `text` column, joined by commas. An
107
+ option id may therefore not contain a comma; the checker refuses one that
108
+ does. `toDbValue` / `fromDbValue` do the conversion, and both the browser and
109
+ the server use them, so it is written once.
110
+ * **File** — the column holds the path the upload answered with, not the file.
111
+ The file itself is stored by `@xeplr/factory` and read back through its
112
+ authenticated route; nothing is ever served without a check.
113
+ * **Date and time** — a `timestamp` column, read back as text so no timezone can
114
+ move the moment. A `date` column may be widened to `timestamp` on publish
115
+ (the day becomes midnight); the reverse is refused, as it would lose the time.
94
116
 
95
117
  ### Front-end hooks
96
118
 
@@ -154,16 +176,45 @@ const clean = schemaHandler.applySchema(formSchema(screen), req.body) // throw
154
176
  | `textarea` | string | `minLength`, `maxLength` |
155
177
  | `number` | number | `min`, `max`, `integer` |
156
178
  | `date` | `"YYYY-MM-DD"` | `min`, `max` |
179
+ | `datetime` | `"YYYY-MM-DDTHH:MM"` | `min`, `max` |
157
180
  | `checkbox` | boolean | `required` = must be ticked |
158
181
  | `dropdown` | option id | — |
182
+ | `radio` | option id | — |
183
+ | `multiselect` | array of option ids | `minItems`, `maxItems` |
184
+ | `file` | the stored file's path | `accept` (extensions), `maxSize` (MB) |
185
+ | `stepper` | — | a journey across the top; every other control says which step it is on |
159
186
  | `label` | — | text with a heading, subheading or text preset |
160
187
  | `list` | — | saved records, with New / Edit / Delete |
161
188
 
162
189
  Every control also takes `style` — see [AUTHORING.md](./AUTHORING.md#styles) for the keys. Sizes are pixels at the screen's design `width` (default 800): a screen is shown at that size, never stretched, and scaled down only on a narrower display.
163
190
 
164
- ### Dropdown options
191
+ ### Steps
192
+
193
+ A **stepper** turns one screen into several: a bar of steps across the top, and
194
+ one step's controls showing at a time. Type the steps as one line — `Connect,
195
+ Transform, Review` — and **click a step on the canvas to build it**: what you
196
+ drop while it is open belongs to it, and the canvas shows only that step. A
197
+ control can be put on *every* step instead (a heading, a list), which is what
198
+ one with no step means.
199
+
200
+ ```json
201
+ { "id": "n7", "type": "stepper", "props": { "steps": [{ "key": "connect", "label": "Connect" }, { "key": "review", "label": "Review" }] } }
202
+ { "id": "n8", "type": "text", "step": { "of": "n7", "index": 0 }, "props": { "name": "host", "label": "Host" } }
203
+ ```
204
+
205
+ `step` sits on the **node**, beside `x`/`y`/`w`/`h` — not in `props`. The
206
+ document stays flat, so a control is still moved, styled, checked and saved
207
+ exactly as before; the step only decides when it is on screen. Renaming a step
208
+ keeps its `key`, so the controls on it stay where they are.
209
+
210
+ **A step is not a separate form.** Every field is a column, is checked, and is
211
+ saved, whichever step is showing — a screen still saves itself as it is filled
212
+ in. **Next** holds at a step whose own fields are not filled in, and says which,
213
+ rather than moving on and leaving a problem behind where nobody is looking.
214
+
215
+ ### Options: dropdown, radio group, multi-select
165
216
 
166
- A dropdown **always saves an `id` and shows a `name`**, from either source:
217
+ All three **save an `id` and show a `name`**, from either source:
167
218
 
168
219
  ```json
169
220
  { "source": "static", "options": [{ "id": "ft", "name": "Full time" }, { "id": "pt", "name": "Part time" }] }
@@ -196,7 +247,7 @@ const screen = screenFromSpec({
196
247
  It lays the fields out as a tidy one- or two-column form and returns a checked document. The same is available on the command line:
197
248
 
198
249
  ```sh
199
- npx xeplr-factory screens entity.json -o dir # an entity → list + edit screens and pages
250
+ npx xeplr-factory screens entity.json -o dir # an entity → list + edit screens, pages, server hooks + model (--no-pages, --force)
200
251
  npx xeplr-factory migration edit.screen.json # preview the SQL Publish would run
201
252
  npx xeplr-factory generate spec.json -o screen.json # one screen from a spec
202
253
  npx xeplr-factory validate screen.json # every problem, with its path
@@ -248,9 +299,10 @@ src/
248
299
  validateDocument.js ─ the document checker
249
300
  values.js ─ values, validation, autosave readiness, records and list columns, formSchema
250
301
  generate.js ─ screenFromSpec, screensFromSpec (list + edit), entity names
251
- scaffold.js ─ an entity → its screen JSON, .jsx pages and hooks stub
302
+ scaffold.js ─ an entity → its screen JSON, .jsx pages (with front-end hooks), server hooks + model stubs
303
+ hooks.js ─ FactoryHooks: get / save / delete / actions — extend and call super
252
304
  tableSchema.js ─ a form → its table; the publish plan (create / add / widen / confirmed drops)
253
- remote.js ─ createFactoryApi: the calls to @xeplr/factory's routes
305
+ remote.js ─ createFactoryApi({ fetch }): the calls to @xeplr/factory's routes — fetch may be window.fetch or @xeplr/ui-account's authFetch (which returns parsed bodies); includes createEntity({ key, label })
254
306
  model.js ─ everything above, React-free
255
307
  useFactoryBuilder.js ─ builder controller
256
308
  useFactoryScreen.js ─ screen controller
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xeplr/ui-factory",
3
- "version": "1.0.5",
3
+ "version": "1.0.7",
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",
package/src/controls.js CHANGED
@@ -101,6 +101,60 @@ const FIELD_BASICS = (extra) => ({
101
101
 
102
102
  const PLACEHOLDER = { path: 'props.placeholder', label: 'Placeholder', type: 'text' }
103
103
 
104
+ /**
105
+ * A multi-select keeps its chosen ids in ONE text column, joined by this. An
106
+ * option id may therefore not contain it — the document checker refuses one
107
+ * that does, rather than letting a value split itself in two on the way back.
108
+ */
109
+ export const MULTI_SEPARATOR = ','
110
+
111
+ /** The controls whose value is chosen from options — static, or a table's rows. */
112
+ export const CHOICE_TYPES = ['dropdown', 'radio', 'multiselect']
113
+
114
+ /** How a group of options is laid out — one per line, or side by side. */
115
+ export const LAYOUTS = ['vertical', 'horizontal']
116
+
117
+ const LAYOUT = { path: 'props.layout', label: 'Arrangement', type: 'select', options: [
118
+ { value: 'vertical', label: 'One per line' },
119
+ { value: 'horizontal', label: 'Side by side' }
120
+ ] }
121
+
122
+ /**
123
+ * What a file field may accept, as EXTENSIONS. Extensions, not MIME types,
124
+ * because that is how people describe a file ("a PDF", "a spreadsheet") and
125
+ * how the server checks it — a browser's reported type is the sender's word
126
+ * for it, and can be anything.
127
+ */
128
+ export const FILE_KINDS = {
129
+ document: { label: 'Documents', accept: '.pdf,.doc,.docx,.odt,.rtf,.txt' },
130
+ pdf: { label: 'PDF only', accept: '.pdf' },
131
+ spreadsheet: { label: 'Spreadsheets', accept: '.xls,.xlsx,.csv,.ods' },
132
+ image: { label: 'Images', accept: '.png,.jpg,.jpeg,.gif,.webp,.svg' },
133
+ any: { label: 'Any of the above', accept: '.pdf,.doc,.docx,.odt,.rtf,.txt,.xls,.xlsx,.csv,.ods,.png,.jpg,.jpeg,.gif,.webp,.svg' }
134
+ }
135
+
136
+ /** An extension list as one string → the same list, cleaned and lower-cased. */
137
+ export function acceptList(accept) {
138
+ return String(accept || '')
139
+ .split(',')
140
+ .map((s) => s.trim().toLowerCase())
141
+ .filter(Boolean)
142
+ .map((s) => (s.startsWith('.') ? s : '.' + s))
143
+ }
144
+
145
+ /**
146
+ * A STEP a control belongs to, on the node itself (beside x/y/w/h/z) rather
147
+ * than in its props: it is not something the control does, it is where the
148
+ * control is. A node with no `step` is on the screen whatever step is showing
149
+ * — a heading, a list, the stepper itself.
150
+ *
151
+ * node.step = { of: '<the stepper node's id>', index: 0 }
152
+ */
153
+ export const STEP_LIMIT = 12
154
+
155
+ /** Largest upload a file field may take, in bytes. */
156
+ export const MAX_FILE_MB = 200
157
+
104
158
  export const CONTROLS = {
105
159
  text: {
106
160
  type: 'text',
@@ -248,6 +302,143 @@ export const CONTROLS = {
248
302
  ]
249
303
  },
250
304
 
305
+ radio: {
306
+ type: 'radio',
307
+ label: 'Radio group',
308
+ group: 'Inputs',
309
+ input: true,
310
+ // One of the options' ids — a table's may be numbers, like a dropdown.
311
+ valueType: null,
312
+ defaultSize: { w: 0.44, h: 0.14 },
313
+ defaults: { label: 'Choose one', data: { source: 'static', options: [] }, layout: 'vertical' },
314
+ props: ['name', 'label', 'required', 'default', 'data', 'layout', 'style'],
315
+ validation: [],
316
+ styles: INPUT_STYLES.filter((k) => k !== 'textAlign'),
317
+ properties: [
318
+ FIELD_BASICS(),
319
+ {
320
+ key: 'data',
321
+ title: 'Options',
322
+ fields: [
323
+ { path: 'props.data', label: 'Options come from', type: 'dataSource' },
324
+ LAYOUT
325
+ ]
326
+ },
327
+ ...styleGroups(INPUT_STYLES.filter((k) => k !== 'textAlign'))
328
+ ]
329
+ },
330
+
331
+ multiselect: {
332
+ type: 'multiselect',
333
+ label: 'Multi-select',
334
+ group: 'Inputs',
335
+ input: true,
336
+ // Several ids. Stored in ONE text column, joined by commas — which is why
337
+ // an option's id may not contain one (the document checker says so).
338
+ valueType: 'array',
339
+ defaultSize: { w: 0.44, h: 0.18 },
340
+ defaults: { label: 'Choose any', data: { source: 'static', options: [] }, layout: 'vertical' },
341
+ props: ['name', 'label', 'required', 'data', 'layout', 'validation', 'style'],
342
+ validation: ['minItems', 'maxItems'],
343
+ styles: INPUT_STYLES.filter((k) => k !== 'textAlign'),
344
+ properties: [
345
+ FIELD_BASICS(),
346
+ {
347
+ key: 'data',
348
+ title: 'Options',
349
+ fields: [
350
+ { path: 'props.data', label: 'Options come from', type: 'dataSource' },
351
+ LAYOUT
352
+ ]
353
+ },
354
+ {
355
+ key: 'validation',
356
+ title: 'Validation',
357
+ fields: [
358
+ { path: 'props.validation.minItems', label: 'Choose at least', type: 'number' },
359
+ { path: 'props.validation.maxItems', label: 'Choose at most', type: 'number' }
360
+ ]
361
+ },
362
+ ...styleGroups(INPUT_STYLES.filter((k) => k !== 'textAlign'))
363
+ ]
364
+ },
365
+
366
+ datetime: {
367
+ type: 'datetime',
368
+ label: 'Date & time',
369
+ group: 'Inputs',
370
+ input: true,
371
+ valueType: 'date',
372
+ defaultSize: { w: 0.44, h: 0.08 },
373
+ defaults: { label: 'Date and time' },
374
+ props: ['name', 'label', 'required', 'default', 'validation', 'style'],
375
+ validation: ['min', 'max'],
376
+ styles: INPUT_STYLES,
377
+ properties: [
378
+ FIELD_BASICS([{ path: 'props.default', label: 'Default value', type: 'datetime' }]),
379
+ {
380
+ key: 'validation',
381
+ title: 'Validation',
382
+ fields: [
383
+ { path: 'props.validation.min', label: 'Earliest', type: 'datetime' },
384
+ { path: 'props.validation.max', label: 'Latest', type: 'datetime' }
385
+ ]
386
+ },
387
+ ...styleGroups(INPUT_STYLES)
388
+ ]
389
+ },
390
+
391
+ file: {
392
+ type: 'file',
393
+ label: 'File upload',
394
+ group: 'Inputs',
395
+ input: true,
396
+ // The value is the stored file's path, as the upload answered with it.
397
+ valueType: 'string',
398
+ defaultSize: { w: 0.44, h: 0.1 },
399
+ defaults: { label: 'File', accept: FILE_KINDS.document.accept, maxSize: 10 },
400
+ props: ['name', 'label', 'required', 'accept', 'maxSize', 'style'],
401
+ validation: [],
402
+ styles: INPUT_STYLES.filter((k) => k !== 'textAlign'),
403
+ properties: [
404
+ FIELD_BASICS(),
405
+ {
406
+ key: 'file',
407
+ title: 'File',
408
+ fields: [
409
+ { path: 'props.accept', label: 'Allowed types', type: 'accept', help: 'Extensions, comma separated — the server refuses anything else' },
410
+ { path: 'props.maxSize', label: 'Largest size (MB)', type: 'number', min: 1, max: 200 }
411
+ ]
412
+ },
413
+ ...styleGroups(INPUT_STYLES.filter((k) => k !== 'textAlign'))
414
+ ]
415
+ },
416
+
417
+ stepper: {
418
+ type: 'stepper',
419
+ label: 'Stepper',
420
+ group: 'Content',
421
+ input: false,
422
+ defaultSize: { w: 0.92, h: 0.09 },
423
+ // Steps are typed as one line — "Connect, Transform, Review" — because
424
+ // that is how someone describes them. Each becomes { key, label }.
425
+ defaults: { steps: [{ key: 'step_1', label: 'Step 1' }, { key: 'step_2', label: 'Step 2' }], showNumbers: true },
426
+ props: ['steps', 'showNumbers', 'style'],
427
+ validation: [],
428
+ styles: ['fontFamily', 'fontSize', 'fontWeight', 'color', 'background', 'borderColor', 'borderWidth', 'borderRadius'],
429
+ properties: [
430
+ {
431
+ key: 'steps',
432
+ title: 'Steps',
433
+ fields: [
434
+ { path: 'props.steps', label: 'Steps', type: 'steps', help: 'One line, separated by commas. Click a step on the canvas to build it.' },
435
+ { path: 'props.showNumbers', label: 'Number the steps', type: 'toggle' }
436
+ ]
437
+ },
438
+ ...styleGroups(['fontFamily', 'fontSize', 'fontWeight', 'color', 'background', 'borderColor', 'borderWidth', 'borderRadius'])
439
+ ]
440
+ },
441
+
251
442
  label: {
252
443
  type: 'label',
253
444
  label: 'Label',
@@ -117,11 +117,36 @@ export default function BuilderSample({ ctrl, renderPreview, className, style })
117
117
  onDragOver={(e) => { if ([...e.dataTransfer.types].includes(DRAG_TYPE)) { e.preventDefault(); e.dataTransfer.dropEffect = 'copy' } }}
118
118
  onDrop={onDrop}
119
119
  >
120
+ {ctrl.steps.building && (
121
+ <div className="xeplr-factory-building" style={{ maxWidth: doc.width }}>
122
+ <span className="xeplr-factory-building-what">Building</span>
123
+ {ctrl.steps.labels(ctrl.steps.building).map((s2, i) => (
124
+ <button
125
+ key={s2.key}
126
+ type="button"
127
+ className={'xeplr-factory-building-step' + (i === ctrl.steps.active(ctrl.steps.building) ? ' is-active' : '')}
128
+ onClick={() => ctrl.steps.go(ctrl.steps.building, i)}
129
+ >
130
+ {s2.label}
131
+ </button>
132
+ ))}
133
+ {ctrl.selectedNode && ctrl.selectedNode.type !== 'stepper' && (
134
+ <label className="xeplr-factory-building-every">
135
+ <input
136
+ type="checkbox"
137
+ checked={!ctrl.steps.of(ctrl.selectedNode)}
138
+ onChange={(e) => ctrl.steps.set(ctrl.selectedNode.id, e.target.checked ? null : { of: ctrl.steps.building, index: ctrl.steps.active(ctrl.steps.building) })}
139
+ />
140
+ <span>Show the selected control on every step</span>
141
+ </label>
142
+ )}
143
+ </div>
144
+ )}
120
145
  <div className="xeplr-factory-frame" style={{ maxWidth: doc.width }}>
121
146
  <XeplrCanvas
122
147
  className="xeplr-factory-canvas"
123
148
  style={screenStyle(doc)}
124
- items={doc.nodes}
149
+ items={ctrl.visibleNodes}
125
150
  units="fraction"
126
151
  pageAspect={doc.aspect}
127
152
  features={{ resize: true, marquee: true }}
@@ -131,11 +156,20 @@ export default function BuilderSample({ ctrl, renderPreview, className, style })
131
156
  minSizeFor={() => ({ minWidth: 16, minHeight: 12 })}
132
157
  renderItem={(node, { selected }) => (
133
158
  <div className={`xeplr-factory-design-node${ctrl.errorsByNode[node.id] ? ' has-error' : ''}${selected ? ' is-selected' : ''}`}>
134
- <ControlView node={node} mode="design" list={node.type === 'list' ? { doc } : undefined} />
159
+ <ControlView
160
+ node={node}
161
+ mode="design"
162
+ list={node.type === 'list' ? { doc } : undefined}
163
+ // Clicking a step on the canvas OPENS it: the canvas then
164
+ // shows that step's controls, and what you drop joins them.
165
+ stepper={node.type === 'stepper' ? { active: ctrl.steps.active(node.id), onStep: (i) => ctrl.steps.go(node.id, i) } : undefined}
166
+ />
135
167
  </div>
136
168
  )}
137
- underlay={doc.nodes.length === 0 && (
138
- <div className="xeplr-factory-empty">Drag controls here from the left</div>
169
+ underlay={ctrl.visibleNodes.length === 0 && (
170
+ <div className="xeplr-factory-empty">
171
+ {doc.nodes.length === 0 ? 'Drag controls here from the left' : 'This step is empty — drop a control to start it'}
172
+ </div>
139
173
  )}
140
174
  />
141
175
  </div>