@workbench-kit/shell-react 0.0.2-prototype.0.2.41 → 0.0.2-prototype.0.2.44

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.
Files changed (39) hide show
  1. package/README.md +81 -2
  2. package/package.json +14 -10
  3. package/src/editor/workspace-reconcile.tsx +12 -7
  4. package/src/extensions/extension-enablement-controller.ts +40 -9
  5. package/src/extensions/theme-selection-protection.ts +80 -55
  6. package/src/field-remap/chrome-labels.ts +117 -0
  7. package/src/field-remap/convert-note-editor.tsx +119 -104
  8. package/src/field-remap/convert-palette.tsx +6 -0
  9. package/src/field-remap/demo.tsx +8 -2
  10. package/src/field-remap/detail-panel.tsx +535 -434
  11. package/src/field-remap/document-io.tsx +299 -0
  12. package/src/field-remap/drag-payload.ts +48 -0
  13. package/src/field-remap/flow-adapter.ts +216 -88
  14. package/src/field-remap/flow-ops.ts +150 -0
  15. package/src/field-remap/flow.tsx +1227 -272
  16. package/src/field-remap/index.ts +2 -0
  17. package/src/field-remap/io-class-browse.tsx +34 -22
  18. package/src/field-remap/keyboard.ts +16 -0
  19. package/src/field-remap/modal-detail.tsx +34 -0
  20. package/src/field-remap/panel.tsx +110 -14
  21. package/src/field-remap/transform-options-editor.tsx +68 -48
  22. package/src/field-remap/view.css +107 -189
  23. package/src/index.ts +7 -0
  24. package/src/keybinding-management-settings.ts +4 -0
  25. package/src/management/keybinding-overrides-storage.ts +48 -23
  26. package/src/management/keybinding-settings-view.tsx +31 -0
  27. package/src/management/keybinding-settings.tsx +4 -12
  28. package/src/management/use-keybinding-management.ts +72 -22
  29. package/src/shell/appearance-catalog.ts +453 -0
  30. package/src/shell/appearance-controller.ts +331 -0
  31. package/src/shell/appearance-presentation.ts +269 -0
  32. package/src/shell/provider.tsx +157 -19
  33. package/src/shell/settings.tsx +282 -153
  34. package/src/shell/shell.tsx +88 -18
  35. package/src/workbench/appearance-storage.ts +2 -2
  36. package/src/workbench/command-host-controller.tsx +223 -0
  37. package/src/workbench/command-host.tsx +100 -150
  38. package/src/workbench/keybinding-bridge.ts +84 -38
  39. package/src/workbench/shell-command-registration.ts +27 -2
@@ -1,5 +1,13 @@
1
- import { useMemo, useState, type JSX } from 'react';
2
- import { Button } from '@workbench-kit/react/primitives';
1
+ import { useId, useMemo, useState, type JSX } from 'react';
2
+ import { WorkbenchPropertyInline } from '@workbench-kit/react/layout';
3
+ import {
4
+ Button,
5
+ Select,
6
+ WorkbenchPropertyHint,
7
+ WorkbenchPropertyRow,
8
+ WorkbenchPropertySection,
9
+ WorkbenchPropertyStack,
10
+ } from '@workbench-kit/react/primitives';
3
11
  import {
4
12
  MAX_TRANSFORM_CHAIN,
5
13
  findSourceField,
@@ -45,6 +53,7 @@ export interface FieldRemapDetailPanelProps {
45
53
  readonly onDiscardDraft?: ((localId: string) => void) | undefined;
46
54
  readonly operators?: readonly MappingOperator[] | undefined;
47
55
  readonly onOperatorsChange?: ((operators: readonly MappingOperator[]) => void) | undefined;
56
+ readonly readOnly?: boolean | undefined;
48
57
  readonly emptyDetailTitle?: string | undefined;
49
58
  readonly emptyDetailDescription?: string | undefined;
50
59
  }
@@ -79,6 +88,7 @@ export function FieldRemapDetailPanel({
79
88
  onDiscardDraft,
80
89
  operators = [],
81
90
  onOperatorsChange,
91
+ readOnly = false,
82
92
  emptyDetailTitle = 'Start with a convert',
83
93
  emptyDetailDescription = 'Use the Convert palette to place a convert, then wire source → draft → target. Or select an existing binding / convert note on the canvas.',
84
94
  }: FieldRemapDetailPanelProps): JSX.Element | null {
@@ -107,8 +117,22 @@ export function FieldRemapDetailPanel({
107
117
  const [paletteId, setPaletteId] = useState<string>('');
108
118
  const [pendingOperatorFieldId, setPendingOperatorFieldId] = useState('');
109
119
  const [pendingOperatorSlotId, setPendingOperatorSlotId] = useState('');
120
+ const propertyId = useId();
121
+ const operatorAddInputId = `${propertyId}-operator-add-input`;
122
+ const operatorOutputId = `${propertyId}-operator-output`;
123
+ const operatorInputId = `${propertyId}-operator-input`;
124
+ const operatorAddOutputId = `${propertyId}-operator-add-output`;
125
+ const paletteSelectId = `${propertyId}-palette-select`;
126
+ const itemSourceId = `${propertyId}-item-source`;
127
+ const itemTargetId = `${propertyId}-item-target`;
110
128
  const flatSources = useMemo(() => flattenSourceFields(sources), [sources]);
111
129
  const flatTargets = useMemo(() => flattenTargetSlots(targets), [targets]);
130
+ const commitOperators = (next: readonly MappingOperator[]) => {
131
+ if (readOnly) {
132
+ return;
133
+ }
134
+ onOperatorsChange?.(next);
135
+ };
112
136
 
113
137
  if (!selection) {
114
138
  return (
@@ -117,8 +141,16 @@ export function FieldRemapDetailPanel({
117
141
  data-testid="field-remap-detail"
118
142
  aria-label="Binding details"
119
143
  >
120
- <h4>{emptyDetailTitle}</h4>
121
- <p data-testid="field-remap-detail-empty-hint">{emptyDetailDescription}</p>
144
+ <WorkbenchPropertyStack gap="sm">
145
+ <WorkbenchPropertySection title={emptyDetailTitle}>
146
+ <WorkbenchPropertyHint
147
+ className="workbench-field-remap-detail__muted"
148
+ data-testid="field-remap-detail-empty-hint"
149
+ >
150
+ {emptyDetailDescription}
151
+ </WorkbenchPropertyHint>
152
+ </WorkbenchPropertySection>
153
+ </WorkbenchPropertyStack>
122
154
  </aside>
123
155
  );
124
156
  }
@@ -131,58 +163,71 @@ export function FieldRemapDetailPanel({
131
163
  data-testid="field-remap-detail"
132
164
  aria-label="Draft convert"
133
165
  >
134
- <div className="workbench-field-remap-detail__header">
135
- <div>
136
- <h4>Draft convert</h4>
137
- <code data-testid="field-remap-detail-draft-id">
138
- {definition?.label ?? draft?.transformId ?? selection.localId}
139
- </code>
140
- </div>
141
- <Button
142
- compact
143
- type="button"
144
- data-testid="field-remap-detail-discard-draft"
145
- onClick={() => {
146
- onDiscardDraft?.(selection.localId);
147
- onSelectionChange(null);
148
- }}
166
+ <WorkbenchPropertyStack gap="sm">
167
+ <WorkbenchPropertySection
168
+ title="Draft convert"
169
+ actions={
170
+ !readOnly ? (
171
+ <Button
172
+ compact
173
+ type="button"
174
+ data-testid="field-remap-detail-discard-draft"
175
+ onClick={() => {
176
+ onDiscardDraft?.(selection.localId);
177
+ onSelectionChange(null);
178
+ }}
179
+ >
180
+ Discard
181
+ </Button>
182
+ ) : null
183
+ }
149
184
  >
150
- Discard
151
- </Button>
152
- </div>
153
- <p className="workbench-field-remap-detail__muted">
154
- Wire source then target ports. When both bind, the convert note editor opens.
155
- </p>
156
- <dl
157
- className="workbench-field-remap-detail__draft-ports"
158
- data-testid="field-remap-detail-draft-ports"
159
- >
160
- <div>
161
- <dt>Source</dt>
162
- <dd>
163
- <code>{draft?.sourceFieldId ?? 'Unwired'}</code>
164
- </dd>
165
- </div>
166
- <div>
167
- <dt>Target</dt>
168
- <dd>
169
- <code>{draft?.targetSlotId ?? 'Unwired'}</code>
170
- </dd>
171
- </div>
172
- </dl>
185
+ <WorkbenchPropertyStack gap="sm">
186
+ <WorkbenchPropertyRow label="Convert">
187
+ <code data-testid="field-remap-detail-draft-id">
188
+ {definition?.label ?? draft?.transformId ?? selection.localId}
189
+ </code>
190
+ </WorkbenchPropertyRow>
191
+ <WorkbenchPropertyHint className="workbench-field-remap-detail__muted">
192
+ Wire source then target ports. When both bind, the convert note editor opens.
193
+ </WorkbenchPropertyHint>
194
+ </WorkbenchPropertyStack>
195
+ </WorkbenchPropertySection>
196
+ <WorkbenchPropertySection
197
+ level="group"
198
+ title="Ports"
199
+ data-testid="field-remap-detail-draft-ports"
200
+ >
201
+ <WorkbenchPropertyStack gap="xs">
202
+ <WorkbenchPropertyRow label="Source">
203
+ <code>{draft?.sourceFieldId ?? 'Unwired'}</code>
204
+ </WorkbenchPropertyRow>
205
+ <WorkbenchPropertyRow label="Target">
206
+ <code>{draft?.targetSlotId ?? 'Unwired'}</code>
207
+ </WorkbenchPropertyRow>
208
+ </WorkbenchPropertyStack>
209
+ </WorkbenchPropertySection>
210
+ </WorkbenchPropertyStack>
173
211
  </aside>
174
212
  );
175
213
  }
176
214
 
177
215
  if (selection.kind === 'operator') {
178
- if (!operator || !onOperatorsChange) {
216
+ if (!operator || (!readOnly && !onOperatorsChange)) {
179
217
  return (
180
218
  <aside className="workbench-field-remap-detail" data-testid="field-remap-detail">
181
- <h4>Operator</h4>
182
- <code data-testid="field-remap-detail-operator-id">{selection.operatorId}</code>
183
- <p className="workbench-field-remap-detail__muted">
184
- Host must wire onOperatorsChange for authoring.
185
- </p>
219
+ <WorkbenchPropertyStack gap="sm">
220
+ <WorkbenchPropertySection title="Operator">
221
+ <WorkbenchPropertyStack gap="sm">
222
+ <WorkbenchPropertyRow label="Identifier">
223
+ <code data-testid="field-remap-detail-operator-id">{selection.operatorId}</code>
224
+ </WorkbenchPropertyRow>
225
+ <WorkbenchPropertyHint className="workbench-field-remap-detail__muted">
226
+ Host must wire onOperatorsChange for authoring.
227
+ </WorkbenchPropertyHint>
228
+ </WorkbenchPropertyStack>
229
+ </WorkbenchPropertySection>
230
+ </WorkbenchPropertyStack>
186
231
  </aside>
187
232
  );
188
233
  }
@@ -192,192 +237,217 @@ export function FieldRemapDetailPanel({
192
237
  data-testid="field-remap-detail"
193
238
  aria-label="Operator details"
194
239
  >
195
- <div className="workbench-field-remap-detail__header">
196
- <div>
197
- <h4>{operator.kind === 'combine' ? 'Combine (n→1)' : 'Split (1→n)'}</h4>
198
- <code data-testid="field-remap-detail-operator-id">{operator.id}</code>
199
- </div>
200
- <Button
201
- compact
202
- type="button"
203
- data-testid="field-remap-detail-delete-operator"
204
- onClick={() => {
205
- onOperatorsChange(removeMappingOperator(operators, operator.id));
206
- onSelectionChange(null);
207
- }}
240
+ <WorkbenchPropertyStack gap="sm">
241
+ <WorkbenchPropertySection
242
+ title={operator.kind === 'combine' ? 'Combine (n→1)' : 'Split (1→n)'}
243
+ actions={
244
+ !readOnly ? (
245
+ <Button
246
+ compact
247
+ type="button"
248
+ data-testid="field-remap-detail-delete-operator"
249
+ onClick={() => {
250
+ commitOperators(removeMappingOperator(operators, operator.id));
251
+ onSelectionChange(null);
252
+ }}
253
+ >
254
+ Delete
255
+ </Button>
256
+ ) : null
257
+ }
208
258
  >
209
- Delete
210
- </Button>
211
- </div>
212
- {operator.kind === 'combine' ? (
213
- <section className="workbench-field-remap-detail__section">
214
- <h5>Inputs</h5>
215
- <ul className="workbench-field-remap-detail__item-edges">
216
- {operator.inputFieldIds.map((fieldId) => (
217
- <li key={fieldId}>
218
- <code>{fieldLabel(fieldId, sources, targets)}</code>
219
- <Button
220
- compact
221
- type="button"
222
- onClick={() =>
223
- onOperatorsChange(
259
+ <WorkbenchPropertyRow label="Identifier">
260
+ <code data-testid="field-remap-detail-operator-id">{operator.id}</code>
261
+ </WorkbenchPropertyRow>
262
+ </WorkbenchPropertySection>
263
+ {operator.kind === 'combine' ? (
264
+ <WorkbenchPropertySection level="group" title="Inputs">
265
+ <WorkbenchPropertyStack gap="sm">
266
+ <ul className="workbench-field-remap-detail__item-edges">
267
+ {operator.inputFieldIds.map((fieldId) => (
268
+ <li key={fieldId}>
269
+ <WorkbenchPropertyInline justify="between">
270
+ <code>{fieldLabel(fieldId, sources, targets)}</code>
271
+ {!readOnly ? (
272
+ <Button
273
+ compact
274
+ type="button"
275
+ onClick={() =>
276
+ commitOperators(
277
+ updateMappingOperator(operators, operator.id, (current) =>
278
+ removeOperatorInput(current, fieldId),
279
+ ),
280
+ )
281
+ }
282
+ >
283
+ Remove
284
+ </Button>
285
+ ) : null}
286
+ </WorkbenchPropertyInline>
287
+ </li>
288
+ ))}
289
+ </ul>
290
+ {!readOnly ? (
291
+ <WorkbenchPropertyRow label="Add input" htmlFor={operatorAddInputId}>
292
+ <WorkbenchPropertyInline>
293
+ <Select
294
+ id={operatorAddInputId}
295
+ aria-label="Add input"
296
+ controlWidth="full"
297
+ data-testid="field-remap-operator-add-input"
298
+ value={pendingOperatorFieldId}
299
+ onValueChange={setPendingOperatorFieldId}
300
+ >
301
+ <option value="">Select…</option>
302
+ {flatSources.map((field) => (
303
+ <option key={field.id} value={field.id}>
304
+ {field.path ?? field.label}
305
+ </option>
306
+ ))}
307
+ </Select>
308
+ <Button
309
+ compact
310
+ type="button"
311
+ data-testid="field-remap-operator-bind-input"
312
+ disabled={!pendingOperatorFieldId}
313
+ onClick={() => {
314
+ if (!pendingOperatorFieldId) return;
315
+ commitOperators(
316
+ updateMappingOperator(operators, operator.id, (current) =>
317
+ bindOperatorInput(current, pendingOperatorFieldId),
318
+ ),
319
+ );
320
+ setPendingOperatorFieldId('');
321
+ }}
322
+ >
323
+ Add
324
+ </Button>
325
+ </WorkbenchPropertyInline>
326
+ </WorkbenchPropertyRow>
327
+ ) : null}
328
+ <WorkbenchPropertyRow label="Output slot" htmlFor={operatorOutputId}>
329
+ <Select
330
+ id={operatorOutputId}
331
+ aria-label="Output slot"
332
+ controlWidth="full"
333
+ data-testid="field-remap-operator-output"
334
+ value={operator.outputSlotId}
335
+ disabled={readOnly}
336
+ onValueChange={(slotId) => {
337
+ commitOperators(
224
338
  updateMappingOperator(operators, operator.id, (current) =>
225
- removeOperatorInput(current, fieldId),
339
+ slotId
340
+ ? bindOperatorOutput(current, slotId)
341
+ : removeOperatorOutput(current, operator.outputSlotId),
226
342
  ),
227
- )
228
- }
343
+ );
344
+ }}
229
345
  >
230
- Remove
231
- </Button>
232
- </li>
233
- ))}
234
- </ul>
235
- <div className="workbench-field-remap-detail__field">
236
- <label>
237
- <span>Add input</span>
238
- <select
239
- data-testid="field-remap-operator-add-input"
240
- value={pendingOperatorFieldId}
241
- onChange={(event) => setPendingOperatorFieldId(event.target.value)}
242
- >
243
- <option value="">Select…</option>
244
- {flatSources.map((field) => (
245
- <option key={field.id} value={field.id}>
246
- {field.path ?? field.label}
247
- </option>
248
- ))}
249
- </select>
250
- </label>
251
- <Button
252
- compact
253
- type="button"
254
- data-testid="field-remap-operator-bind-input"
255
- disabled={!pendingOperatorFieldId}
256
- onClick={() => {
257
- if (!pendingOperatorFieldId) return;
258
- onOperatorsChange(
259
- updateMappingOperator(operators, operator.id, (current) =>
260
- bindOperatorInput(current, pendingOperatorFieldId),
261
- ),
262
- );
263
- setPendingOperatorFieldId('');
264
- }}
265
- >
266
- Add
267
- </Button>
268
- </div>
269
- <div className="workbench-field-remap-detail__field">
270
- <label>
271
- <span>Output slot</span>
272
- <select
273
- data-testid="field-remap-operator-output"
274
- value={operator.outputSlotId}
275
- onChange={(event) => {
276
- const slotId = event.target.value;
277
- onOperatorsChange(
278
- updateMappingOperator(operators, operator.id, (current) =>
279
- slotId
280
- ? bindOperatorOutput(current, slotId)
281
- : removeOperatorOutput(current, operator.outputSlotId),
282
- ),
283
- );
284
- }}
285
- >
286
- <option value="">Unwired…</option>
287
- {flatTargets.map((slot) => (
288
- <option key={slot.id} value={slot.id}>
289
- {slot.path ?? slot.label}
290
- </option>
291
- ))}
292
- </select>
293
- </label>
294
- </div>
295
- </section>
296
- ) : (
297
- <section className="workbench-field-remap-detail__section">
298
- <div className="workbench-field-remap-detail__field">
299
- <label>
300
- <span>Input field</span>
301
- <select
302
- data-testid="field-remap-operator-input"
303
- value={operator.inputFieldId}
304
- onChange={(event) => {
305
- const fieldId = event.target.value;
306
- onOperatorsChange(
307
- updateMappingOperator(operators, operator.id, (current) =>
308
- fieldId
309
- ? bindOperatorInput(current, fieldId)
310
- : removeOperatorInput(current, operator.inputFieldId),
311
- ),
312
- );
313
- }}
314
- >
315
- <option value="">Unwired…</option>
316
- {flatSources.map((field) => (
317
- <option key={field.id} value={field.id}>
318
- {field.path ?? field.label}
319
- </option>
320
- ))}
321
- </select>
322
- </label>
323
- </div>
324
- <h5>Outputs</h5>
325
- <ul className="workbench-field-remap-detail__item-edges">
326
- {operator.outputSlotIds.map((slotId) => (
327
- <li key={slotId}>
328
- <code>{fieldLabel(slotId, sources, targets)}</code>
329
- <Button
330
- compact
331
- type="button"
332
- onClick={() =>
333
- onOperatorsChange(
346
+ <option value="">Unwired…</option>
347
+ {flatTargets.map((slot) => (
348
+ <option key={slot.id} value={slot.id}>
349
+ {slot.path ?? slot.label}
350
+ </option>
351
+ ))}
352
+ </Select>
353
+ </WorkbenchPropertyRow>
354
+ </WorkbenchPropertyStack>
355
+ </WorkbenchPropertySection>
356
+ ) : (
357
+ <WorkbenchPropertySection level="group" title="Outputs">
358
+ <WorkbenchPropertyStack gap="sm">
359
+ <WorkbenchPropertyRow label="Input field" htmlFor={operatorInputId}>
360
+ <Select
361
+ id={operatorInputId}
362
+ aria-label="Input field"
363
+ controlWidth="full"
364
+ data-testid="field-remap-operator-input"
365
+ value={operator.inputFieldId}
366
+ disabled={readOnly}
367
+ onValueChange={(fieldId) => {
368
+ commitOperators(
334
369
  updateMappingOperator(operators, operator.id, (current) =>
335
- removeOperatorOutput(current, slotId),
370
+ fieldId
371
+ ? bindOperatorInput(current, fieldId)
372
+ : removeOperatorInput(current, operator.inputFieldId),
336
373
  ),
337
- )
338
- }
374
+ );
375
+ }}
339
376
  >
340
- Remove
341
- </Button>
342
- </li>
343
- ))}
344
- </ul>
345
- <div className="workbench-field-remap-detail__field">
346
- <label>
347
- <span>Add output</span>
348
- <select
349
- data-testid="field-remap-operator-add-output"
350
- value={pendingOperatorSlotId}
351
- onChange={(event) => setPendingOperatorSlotId(event.target.value)}
352
- >
353
- <option value="">Select…</option>
354
- {flatTargets.map((slot) => (
355
- <option key={slot.id} value={slot.id}>
356
- {slot.path ?? slot.label}
357
- </option>
377
+ <option value="">Unwired…</option>
378
+ {flatSources.map((field) => (
379
+ <option key={field.id} value={field.id}>
380
+ {field.path ?? field.label}
381
+ </option>
382
+ ))}
383
+ </Select>
384
+ </WorkbenchPropertyRow>
385
+ <ul className="workbench-field-remap-detail__item-edges">
386
+ {operator.outputSlotIds.map((slotId) => (
387
+ <li key={slotId}>
388
+ <WorkbenchPropertyInline justify="between">
389
+ <code>{fieldLabel(slotId, sources, targets)}</code>
390
+ {!readOnly ? (
391
+ <Button
392
+ compact
393
+ type="button"
394
+ onClick={() =>
395
+ commitOperators(
396
+ updateMappingOperator(operators, operator.id, (current) =>
397
+ removeOperatorOutput(current, slotId),
398
+ ),
399
+ )
400
+ }
401
+ >
402
+ Remove
403
+ </Button>
404
+ ) : null}
405
+ </WorkbenchPropertyInline>
406
+ </li>
358
407
  ))}
359
- </select>
360
- </label>
361
- <Button
362
- compact
363
- type="button"
364
- data-testid="field-remap-operator-bind-output"
365
- disabled={!pendingOperatorSlotId}
366
- onClick={() => {
367
- if (!pendingOperatorSlotId) return;
368
- onOperatorsChange(
369
- updateMappingOperator(operators, operator.id, (current) =>
370
- bindOperatorOutput(current, pendingOperatorSlotId),
371
- ),
372
- );
373
- setPendingOperatorSlotId('');
374
- }}
375
- >
376
- Add
377
- </Button>
378
- </div>
379
- </section>
380
- )}
408
+ </ul>
409
+ {!readOnly ? (
410
+ <WorkbenchPropertyRow label="Add output" htmlFor={operatorAddOutputId}>
411
+ <WorkbenchPropertyInline>
412
+ <Select
413
+ id={operatorAddOutputId}
414
+ aria-label="Add output"
415
+ controlWidth="full"
416
+ data-testid="field-remap-operator-add-output"
417
+ value={pendingOperatorSlotId}
418
+ onValueChange={setPendingOperatorSlotId}
419
+ >
420
+ <option value="">Select…</option>
421
+ {flatTargets.map((slot) => (
422
+ <option key={slot.id} value={slot.id}>
423
+ {slot.path ?? slot.label}
424
+ </option>
425
+ ))}
426
+ </Select>
427
+ <Button
428
+ compact
429
+ type="button"
430
+ data-testid="field-remap-operator-bind-output"
431
+ disabled={!pendingOperatorSlotId}
432
+ onClick={() => {
433
+ if (!pendingOperatorSlotId) return;
434
+ commitOperators(
435
+ updateMappingOperator(operators, operator.id, (current) =>
436
+ bindOperatorOutput(current, pendingOperatorSlotId),
437
+ ),
438
+ );
439
+ setPendingOperatorSlotId('');
440
+ }}
441
+ >
442
+ Add
443
+ </Button>
444
+ </WorkbenchPropertyInline>
445
+ </WorkbenchPropertyRow>
446
+ ) : null}
447
+ </WorkbenchPropertyStack>
448
+ </WorkbenchPropertySection>
449
+ )}
450
+ </WorkbenchPropertyStack>
381
451
  </aside>
382
452
  );
383
453
  }
@@ -389,23 +459,32 @@ export function FieldRemapDetailPanel({
389
459
  data-testid="field-remap-detail"
390
460
  aria-label="Binding details"
391
461
  >
392
- <p>Select a binding for mapping details, or a convert node to open the convert editor.</p>
462
+ <WorkbenchPropertyStack gap="sm">
463
+ <WorkbenchPropertySection title="Binding">
464
+ <WorkbenchPropertyHint className="workbench-field-remap-detail__muted">
465
+ Select a binding for mapping details, or a convert node to open the convert editor.
466
+ </WorkbenchPropertyHint>
467
+ </WorkbenchPropertySection>
468
+ </WorkbenchPropertyStack>
393
469
  </aside>
394
470
  );
395
471
  }
396
472
 
397
473
  if (selection.kind === 'transformStep') {
398
474
  return (
399
- <ConvertNoteEditor
400
- edge={edge}
401
- stepIndex={selection.stepIndex}
402
- sources={sources}
403
- targets={targets}
404
- transforms={transforms}
405
- edges={edges}
406
- onEdgesChange={onEdgesChange}
407
- onSelectionChange={onSelectionChange}
408
- />
475
+ <div data-testid="field-remap-detail-transform-step">
476
+ <ConvertNoteEditor
477
+ edge={edge}
478
+ stepIndex={selection.stepIndex}
479
+ sources={sources}
480
+ targets={targets}
481
+ transforms={transforms}
482
+ edges={edges}
483
+ onEdgesChange={onEdgesChange}
484
+ onSelectionChange={onSelectionChange}
485
+ readOnly={readOnly}
486
+ />
487
+ </div>
409
488
  );
410
489
  }
411
490
 
@@ -432,7 +511,7 @@ export function FieldRemapDetailPanel({
432
511
  const itemTargetChildren = targetSlot?.children ?? [];
433
512
 
434
513
  const applyEdge = (next: MappingEdge | null) => {
435
- if (!next) {
514
+ if (readOnly || !next) {
436
515
  return;
437
516
  }
438
517
  onEdgesChange(edges.map((item) => (item.id === edge.id ? next : item)));
@@ -444,217 +523,239 @@ export function FieldRemapDetailPanel({
444
523
  data-testid="field-remap-detail"
445
524
  aria-label="Binding details"
446
525
  >
447
- <div className="workbench-field-remap-detail__header">
448
- <div>
449
- <h4>Binding</h4>
450
- <code data-testid="field-remap-detail-binding">
451
- {edge.sourceFieldId} → {edge.targetSlotId}
452
- </code>
453
- </div>
454
- <Button
455
- compact
456
- type="button"
457
- data-testid="field-remap-detail-clear"
458
- onClick={() => onSelectionChange(null)}
526
+ <WorkbenchPropertyStack gap="sm">
527
+ <WorkbenchPropertySection
528
+ title="Binding"
529
+ actions={
530
+ <Button
531
+ compact
532
+ type="button"
533
+ data-testid="field-remap-detail-clear"
534
+ onClick={() => onSelectionChange(null)}
535
+ >
536
+ Clear
537
+ </Button>
538
+ }
459
539
  >
460
- Clear
461
- </Button>
462
- </div>
540
+ <WorkbenchPropertyRow label="Route">
541
+ <code data-testid="field-remap-detail-binding">
542
+ {edge.sourceFieldId} → {edge.targetSlotId}
543
+ </code>
544
+ </WorkbenchPropertyRow>
545
+ </WorkbenchPropertySection>
463
546
 
464
- <section
465
- className="workbench-field-remap-detail__section"
466
- aria-labelledby="field-remap-detail-chain"
467
- >
468
- <h5 id="field-remap-detail-chain">Convert chain</h5>
469
- {chain.length === 0 ? (
470
- <p className="workbench-field-remap-detail__muted">Direct (no convert)</p>
471
- ) : (
472
- <ol className="workbench-field-remap-detail__steps">
473
- {chain.map((transformId, index) => {
474
- const definition = transforms.get(transformId);
475
- return (
476
- <li key={`${edge.id}-${index}-${transformId}`}>
477
- <button
478
- type="button"
479
- className="workbench-field-remap-detail__step"
480
- data-testid={`field-remap-detail-step-${index}`}
481
- onClick={() =>
482
- onSelectionChange({
483
- kind: 'transformStep',
484
- edgeId: edge.id,
485
- stepIndex: index,
486
- })
487
- }
547
+ <WorkbenchPropertySection level="group" title="Convert chain">
548
+ <WorkbenchPropertyStack gap="sm">
549
+ {chain.length === 0 ? (
550
+ <WorkbenchPropertyHint className="workbench-field-remap-detail__muted">
551
+ Direct (no convert)
552
+ </WorkbenchPropertyHint>
553
+ ) : (
554
+ <ol className="workbench-field-remap-detail__steps">
555
+ {chain.map((transformId, index) => {
556
+ const definition = transforms.get(transformId);
557
+ return (
558
+ <li key={`${edge.id}-${index}-${transformId}`}>
559
+ <WorkbenchPropertyInline justify="between">
560
+ <button
561
+ type="button"
562
+ className="workbench-field-remap-detail__step"
563
+ data-testid={`field-remap-detail-step-${index}`}
564
+ onClick={() =>
565
+ onSelectionChange({
566
+ kind: 'transformStep',
567
+ edgeId: edge.id,
568
+ stepIndex: index,
569
+ })
570
+ }
571
+ >
572
+ <strong>{definition?.label ?? transformId}</strong>
573
+ <code>{transformId}</code>
574
+ <WorkbenchPropertyHint className="workbench-field-remap-detail__step-hint">
575
+ {readOnly ? 'Inspect convert' : 'Edit convert'}
576
+ </WorkbenchPropertyHint>
577
+ </button>
578
+ {!readOnly ? (
579
+ <Button
580
+ compact
581
+ type="button"
582
+ aria-label={`Remove convert step ${index + 1}`}
583
+ data-testid={`field-remap-detail-remove-step-${index}`}
584
+ onClick={() => {
585
+ const next = removeTransformStepFromEdge(edge, index);
586
+ applyEdge(next);
587
+ }}
588
+ >
589
+ ×
590
+ </Button>
591
+ ) : null}
592
+ </WorkbenchPropertyInline>
593
+ </li>
594
+ );
595
+ })}
596
+ </ol>
597
+ )}
598
+
599
+ {!readOnly && chain.length < MAX_TRANSFORM_CHAIN ? (
600
+ <WorkbenchPropertyRow label="Add convert" htmlFor={paletteSelectId}>
601
+ <WorkbenchPropertyInline
602
+ className="workbench-field-remap-detail__palette"
603
+ data-testid="field-remap-transform-palette"
604
+ >
605
+ <Select
606
+ id={paletteSelectId}
607
+ aria-label="Convert palette"
608
+ controlWidth="full"
609
+ data-testid="field-remap-palette-select"
610
+ value={effectivePaletteId}
611
+ disabled={appendCatalog.length === 0}
612
+ onValueChange={setPaletteId}
488
613
  >
489
- <strong>{definition?.label ?? transformId}</strong>
490
- <code>{transformId}</code>
491
- <span className="workbench-field-remap-detail__step-hint">Edit convert</span>
492
- </button>
614
+ {appendCatalog.length === 0 ? (
615
+ <option value="">No compatible converts</option>
616
+ ) : null}
617
+ {appendCatalog.map((item) => (
618
+ <option key={item.id} value={item.id}>
619
+ {item.label}
620
+ </option>
621
+ ))}
622
+ </Select>
493
623
  <Button
494
624
  compact
495
625
  type="button"
496
- aria-label={`Remove convert step ${index + 1}`}
497
- data-testid={`field-remap-detail-remove-step-${index}`}
626
+ data-testid="field-remap-palette-add"
627
+ disabled={!effectivePaletteId}
498
628
  onClick={() => {
499
- const next = removeTransformStepFromEdge(edge, index);
629
+ const next = addTransformStepToEdge(edge, effectivePaletteId, {
630
+ registry: transforms,
631
+ sourceType: portTypes.sourceType,
632
+ targetType: portTypes.targetType,
633
+ });
634
+ if (!next) {
635
+ return;
636
+ }
500
637
  applyEdge(next);
638
+ onSelectionChange({
639
+ kind: 'transformStep',
640
+ edgeId: edge.id,
641
+ stepIndex: (next.transformIds?.length ?? 1) - 1,
642
+ });
501
643
  }}
502
644
  >
503
- ×
645
+ Add
504
646
  </Button>
505
- </li>
506
- );
507
- })}
508
- </ol>
509
- )}
647
+ </WorkbenchPropertyInline>
648
+ </WorkbenchPropertyRow>
649
+ ) : null}
650
+ </WorkbenchPropertyStack>
651
+ </WorkbenchPropertySection>
510
652
 
511
- {chain.length < MAX_TRANSFORM_CHAIN ? (
512
- <div
513
- className="workbench-field-remap-detail__palette"
514
- data-testid="field-remap-transform-palette"
653
+ {listContextEnabled ? (
654
+ <WorkbenchPropertySection
655
+ level="group"
656
+ title="List context"
657
+ data-testid="field-remap-list-context"
658
+ actions={
659
+ !readOnly && !edge.itemEdges ? (
660
+ <Button
661
+ compact
662
+ type="button"
663
+ data-testid="field-remap-enable-list-context"
664
+ onClick={() => applyEdge(enableListContextOnEdge(edge))}
665
+ >
666
+ Enable item fields
667
+ </Button>
668
+ ) : null
669
+ }
515
670
  >
516
- <label>
517
- <span>Add convert</span>
518
- <select
519
- aria-label="Convert palette"
520
- data-testid="field-remap-palette-select"
521
- value={effectivePaletteId}
522
- disabled={appendCatalog.length === 0}
523
- onChange={(event) => setPaletteId(event.target.value)}
524
- >
525
- {appendCatalog.length === 0 ? (
526
- <option value="">No compatible converts</option>
671
+ {edge.itemEdges ? (
672
+ <WorkbenchPropertyStack gap="sm">
673
+ {!readOnly ? (
674
+ <WorkbenchPropertyHint className="workbench-field-remap-detail__muted">
675
+ {pendingItemSourceId
676
+ ? 'Select a target item field to complete the item binding.'
677
+ : 'Select a source item field, then a target item field.'}
678
+ </WorkbenchPropertyHint>
527
679
  ) : null}
528
- {appendCatalog.map((item) => (
529
- <option key={item.id} value={item.id}>
530
- {item.label}
531
- </option>
532
- ))}
533
- </select>
534
- </label>
535
- <Button
536
- compact
537
- type="button"
538
- data-testid="field-remap-palette-add"
539
- disabled={!effectivePaletteId}
540
- onClick={() => {
541
- const next = addTransformStepToEdge(edge, effectivePaletteId, {
542
- registry: transforms,
543
- sourceType: portTypes.sourceType,
544
- targetType: portTypes.targetType,
545
- });
546
- if (!next) {
547
- return;
548
- }
549
- applyEdge(next);
550
- onSelectionChange({
551
- kind: 'transformStep',
552
- edgeId: edge.id,
553
- stepIndex: (next.transformIds?.length ?? 1) - 1,
554
- });
555
- }}
556
- >
557
- Add
558
- </Button>
559
- </div>
560
- ) : null}
561
- </section>
562
-
563
- {listContextEnabled ? (
564
- <section
565
- className="workbench-field-remap-detail__section"
566
- aria-labelledby="field-remap-detail-list-context"
567
- data-testid="field-remap-list-context"
568
- >
569
- <div className="workbench-field-remap-detail__list-context-bar">
570
- <h5 id="field-remap-detail-list-context">List context</h5>
571
- {!edge.itemEdges ? (
572
- <Button
573
- compact
574
- type="button"
575
- data-testid="field-remap-enable-list-context"
576
- onClick={() => applyEdge(enableListContextOnEdge(edge))}
577
- >
578
- Enable item fields
579
- </Button>
580
- ) : null}
581
- </div>
582
-
583
- {edge.itemEdges ? (
584
- <>
585
- <p className="workbench-field-remap-detail__muted">
586
- {pendingItemSourceId
587
- ? 'Select a target item field to complete the item binding.'
588
- : 'Select a source item field, then a target item field.'}
589
- </p>
590
- <div className="workbench-field-remap-detail__item-pickers">
591
- <label>
592
- <span>Source item field</span>
593
- <select
594
- aria-label="Source item field"
595
- data-testid="field-remap-item-source"
596
- value={pendingItemSourceId ?? ''}
597
- onChange={(event) => setPendingItemSourceId(event.target.value || null)}
680
+ {!readOnly ? (
681
+ <WorkbenchPropertyStack
682
+ className="workbench-field-remap-detail__item-pickers"
683
+ gap="xs"
598
684
  >
599
- <option value="">Select…</option>
600
- {itemSourceChildren.map((child) => (
601
- <option key={child.id} value={child.id}>
602
- {child.path ?? child.label}
603
- </option>
604
- ))}
605
- </select>
606
- </label>
607
- <label>
608
- <span>Target item field</span>
609
- <select
610
- aria-label="Target item field"
611
- data-testid="field-remap-item-target"
612
- value=""
613
- disabled={!pendingItemSourceId}
614
- onChange={(event) => {
615
- const targetId = event.target.value;
616
- if (!pendingItemSourceId || !targetId) {
617
- return;
618
- }
619
- const itemEdge: MappingEdge = {
620
- id: `ie-${pendingItemSourceId}-${targetId}-${Date.now()}`,
621
- sourceFieldId: pendingItemSourceId,
622
- targetSlotId: targetId,
623
- };
624
- applyEdge(upsertItemEdgeOnParent(edge, itemEdge));
625
- setPendingItemSourceId(null);
626
- }}
627
- >
628
- <option value="">Select…</option>
629
- {itemTargetChildren.map((child) => (
630
- <option key={child.id} value={child.id}>
631
- {child.path ?? child.label}
632
- </option>
633
- ))}
634
- </select>
635
- </label>
636
- </div>
637
- <ul className="workbench-field-remap-detail__item-edges">
638
- {(edge.itemEdges ?? []).map((itemEdge) => (
639
- <li key={itemEdge.id} data-testid={`field-remap-item-edge-${itemEdge.id}`}>
640
- <code>
641
- {fieldLabel(itemEdge.sourceFieldId, sources, targets)} →{' '}
642
- {fieldLabel(itemEdge.targetSlotId, sources, targets)}
643
- </code>
644
- <Button
645
- compact
646
- type="button"
647
- onClick={() => applyEdge(removeItemEdgeFromParent(edge, itemEdge.id))}
648
- >
649
- Remove
650
- </Button>
651
- </li>
652
- ))}
653
- </ul>
654
- </>
655
- ) : null}
656
- </section>
657
- ) : null}
685
+ <WorkbenchPropertyRow label="Source item field" htmlFor={itemSourceId}>
686
+ <Select
687
+ id={itemSourceId}
688
+ aria-label="Source item field"
689
+ controlWidth="full"
690
+ data-testid="field-remap-item-source"
691
+ value={pendingItemSourceId ?? ''}
692
+ onValueChange={(sourceId) => setPendingItemSourceId(sourceId || null)}
693
+ >
694
+ <option value="">Select…</option>
695
+ {itemSourceChildren.map((child) => (
696
+ <option key={child.id} value={child.id}>
697
+ {child.path ?? child.label}
698
+ </option>
699
+ ))}
700
+ </Select>
701
+ </WorkbenchPropertyRow>
702
+ <WorkbenchPropertyRow label="Target item field" htmlFor={itemTargetId}>
703
+ <Select
704
+ id={itemTargetId}
705
+ aria-label="Target item field"
706
+ controlWidth="full"
707
+ data-testid="field-remap-item-target"
708
+ value=""
709
+ disabled={!pendingItemSourceId}
710
+ onValueChange={(targetId) => {
711
+ if (!pendingItemSourceId || !targetId) {
712
+ return;
713
+ }
714
+ const itemEdge: MappingEdge = {
715
+ id: `ie-${pendingItemSourceId}-${targetId}-${Date.now()}`,
716
+ sourceFieldId: pendingItemSourceId,
717
+ targetSlotId: targetId,
718
+ };
719
+ applyEdge(upsertItemEdgeOnParent(edge, itemEdge));
720
+ setPendingItemSourceId(null);
721
+ }}
722
+ >
723
+ <option value="">Select…</option>
724
+ {itemTargetChildren.map((child) => (
725
+ <option key={child.id} value={child.id}>
726
+ {child.path ?? child.label}
727
+ </option>
728
+ ))}
729
+ </Select>
730
+ </WorkbenchPropertyRow>
731
+ </WorkbenchPropertyStack>
732
+ ) : null}
733
+ <ul className="workbench-field-remap-detail__item-edges">
734
+ {(edge.itemEdges ?? []).map((itemEdge) => (
735
+ <li key={itemEdge.id} data-testid={`field-remap-item-edge-${itemEdge.id}`}>
736
+ <WorkbenchPropertyInline justify="between">
737
+ <code>
738
+ {fieldLabel(itemEdge.sourceFieldId, sources, targets)} →{' '}
739
+ {fieldLabel(itemEdge.targetSlotId, sources, targets)}
740
+ </code>
741
+ {!readOnly ? (
742
+ <Button
743
+ compact
744
+ type="button"
745
+ onClick={() => applyEdge(removeItemEdgeFromParent(edge, itemEdge.id))}
746
+ >
747
+ Remove
748
+ </Button>
749
+ ) : null}
750
+ </WorkbenchPropertyInline>
751
+ </li>
752
+ ))}
753
+ </ul>
754
+ </WorkbenchPropertyStack>
755
+ ) : null}
756
+ </WorkbenchPropertySection>
757
+ ) : null}
758
+ </WorkbenchPropertyStack>
658
759
  </aside>
659
760
  );
660
761
  }