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

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 (35) hide show
  1. package/README.md +42 -2
  2. package/package.json +13 -10
  3. package/src/extensions/extension-enablement-controller.ts +40 -9
  4. package/src/extensions/theme-selection-protection.ts +80 -55
  5. package/src/field-remap/chrome-labels.ts +18 -0
  6. package/src/field-remap/convert-note-editor.tsx +30 -24
  7. package/src/field-remap/convert-palette.tsx +6 -0
  8. package/src/field-remap/demo.tsx +8 -2
  9. package/src/field-remap/detail-panel.tsx +225 -192
  10. package/src/field-remap/drag-payload.ts +48 -0
  11. package/src/field-remap/flow-adapter.ts +216 -88
  12. package/src/field-remap/flow-ops.ts +150 -0
  13. package/src/field-remap/flow.tsx +1055 -162
  14. package/src/field-remap/index.ts +2 -0
  15. package/src/field-remap/io-class-browse.tsx +34 -22
  16. package/src/field-remap/keyboard.ts +16 -0
  17. package/src/field-remap/modal-detail.tsx +34 -0
  18. package/src/field-remap/panel.tsx +50 -14
  19. package/src/field-remap/transform-options-editor.tsx +5 -1
  20. package/src/field-remap/view.css +61 -42
  21. package/src/index.ts +7 -0
  22. package/src/management/keybinding-overrides-storage.ts +48 -23
  23. package/src/management/keybinding-settings.tsx +10 -1
  24. package/src/management/use-keybinding-management.ts +69 -19
  25. package/src/shell/appearance-catalog.ts +453 -0
  26. package/src/shell/appearance-controller.ts +331 -0
  27. package/src/shell/appearance-presentation.ts +269 -0
  28. package/src/shell/provider.tsx +147 -18
  29. package/src/shell/settings.tsx +282 -153
  30. package/src/shell/shell.tsx +88 -18
  31. package/src/workbench/appearance-storage.ts +2 -2
  32. package/src/workbench/command-host-controller.tsx +223 -0
  33. package/src/workbench/command-host.tsx +100 -150
  34. package/src/workbench/keybinding-bridge.ts +84 -38
  35. package/src/workbench/shell-command-registration.ts +27 -2
package/README.md CHANGED
@@ -51,6 +51,28 @@ registry can use `registry-command-descriptors` without importing Provider
51
51
  context into that leaf bundle. The root barrel remains the discovery surface,
52
52
  not the default runtime import graph.
53
53
 
54
+ ### Provider-free command host
55
+
56
+ Hosts that already own command descriptors and execution can compose the canonical
57
+ Command Palette and Quick Open without `WorkbenchProvider`:
58
+
59
+ ```tsx
60
+ import { WorkbenchCommandHostController } from '@workbench-kit/shell-react/command-host-controller';
61
+
62
+ <WorkbenchCommandHostController
63
+ commands={commands}
64
+ executeCommand={executeCommand}
65
+ quickOpenProviders={quickOpenProviders}
66
+ />;
67
+ ```
68
+
69
+ `commands` is the complete palette descriptor set. The controller owns only overlay
70
+ state, hard Palette/Quick Open shortcuts, selection routing, and completion-driven
71
+ closing. Hosts continue to own command registration, descriptor projection, Quick Open
72
+ provider construction, persistence, and error reporting. Pass `shortcutBridge` only
73
+ when the host also wants the generic keybinding bridge; omit it or pass `false` to keep
74
+ that routing host-owned.
75
+
54
76
  ### Focused extension context migration
55
77
 
56
78
  `WorkbenchContextValue` no longer exposes the aggregate `ExtensionRegistry`.
@@ -117,8 +139,19 @@ for Flow-only embeds and custom bundler setups. The full barrel
117
139
  when both durable channels are uncontrolled. If either channel is controlled, pass one
118
140
  `historyOwner` for the complete composite state; the Panel never creates a partial stack.
119
141
  `historyActionsRef` exposes host-chrome actions and
120
- `onHistoryAvailabilityChange` reports whether those actions are available. Keyboard
121
- routing remains host-owned.
142
+ `onHistoryAvailabilityChange` reports whether those actions are available. The Panel routes
143
+ available undo/redo chords through that same owner; direct `FieldRemapFlowMapper` consumers
144
+ remain responsible for host-owned history routing.
145
+
146
+ | Surface | Shortcut | Behavior |
147
+ | ------- | ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
148
+ | Flow | `Escape` | Clears the active selection and unfinished drafts. If collapsed detail chrome owned focus, focus moves to the programmatic-only mapper root. |
149
+ | Flow | `Delete` / `Backspace` | Removes the eligible selected edge, convert step, operator, or draft through the existing mutation path. |
150
+ | Panel | `Ctrl/Cmd+Z` | Invokes the existing composite history owner's available undo action. |
151
+ | Panel | `Ctrl/Cmd+Shift+Z`, `Ctrl+Y` | Invokes the existing composite history owner's available redo action. |
152
+
153
+ Editable inputs, textareas, selects, contenteditable elements, and transform option editors
154
+ retain their native key behavior. Unavailable or read-only actions leave the event unconsumed.
122
155
 
123
156
  Only semantic edits coming from the Flow mapper create entries. Hidden mappings are
124
157
  reconstructed before an entry is recorded, so undo does not discard filtered state.
@@ -140,6 +173,13 @@ hooks so hosts avoid CSS/DOM workarounds:
140
173
  | `flowActionsRef` | `{ fitView(options?) }` using the same defaults as Controls fit-view. |
141
174
  | `labels` / `t` | Override edge-list / Convert palette chrome (e.g. “Field maps”). |
142
175
  | `ioChrome` (Panel) | `'browse' \| 'edit' \| 'none'` — prefer browse for inspect-only I/O. |
176
+ | `rewirePolicy` (Flow) | `'replace'` by default; `'reject'` preserves prior edges and reports impacted edge IDs. |
177
+ | `onConnectionFeedback` (Flow) | Receives one structured result at connection-attempt completion; hover validation stays silent. |
178
+ | `parentChildConflicts` (Flow) | Optional authoritative conflict projection; `undefined` derives from the supplied Flow inputs. |
179
+
180
+ Rejected attempts render one compact `role="status"` message. Standalone Flow embeds derive
181
+ parent/child conflicts with the domain detector; Panel computes the same conflicts from full shapes
182
+ before hidden-field projection and supplies that authoritative result, so it renders only once.
143
183
 
144
184
  ```tsx
145
185
  const flowActionsRef = useRef<FieldRemapFlowActions | null>(null);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@workbench-kit/shell-react",
3
- "version": "0.0.2-prototype.0.2.41",
3
+ "version": "0.0.2-prototype.0.2.43",
4
4
  "private": false,
5
5
  "sideEffects": [
6
6
  "**/*.css"
@@ -11,6 +11,7 @@
11
11
  "./command-descriptors": "./src/commands/use-command-descriptors.ts",
12
12
  "./registry-command-descriptors": "./src/commands/use-extension-registry-command-descriptors.ts",
13
13
  "./command-host": "./src/workbench/command-host.tsx",
14
+ "./command-host-controller": "./src/workbench/command-host-controller.tsx",
14
15
  "./command-palette": "./src/workbench/command-palette.ts",
15
16
  "./field-remap": "./src/field-remap/index.ts",
16
17
  "./field-remap/view.css": "./src/field-remap/view.css",
@@ -31,14 +32,14 @@
31
32
  "@radix-ui/react-slot": "^1.3.0",
32
33
  "@xyflow/react": "^12.11.2",
33
34
  "jsonata": "^2.2.0",
34
- "@workbench-kit/platform": "0.0.2-prototype.0.2.41",
35
- "@workbench-kit/field-remap": "0.0.2-prototype.0.2.41",
36
- "@workbench-kit/workbench-extension-sdk": "0.0.2-prototype.0.2.41",
37
- "@workbench-kit/workbench-config": "0.0.2-prototype.0.2.41",
38
- "@workbench-kit/tokens": "0.0.2-prototype.0.2.41",
39
- "@workbench-kit/workbench-core": "0.0.2-prototype.0.2.41",
40
- "@workbench-kit/workspace": "0.0.2-prototype.0.2.41",
41
- "@workbench-kit/react": "0.0.2-prototype.0.2.41"
35
+ "@workbench-kit/platform": "0.0.2-prototype.0.2.43",
36
+ "@workbench-kit/field-remap": "0.0.2-prototype.0.2.43",
37
+ "@workbench-kit/tokens": "0.0.2-prototype.0.2.43",
38
+ "@workbench-kit/react": "0.0.2-prototype.0.2.43",
39
+ "@workbench-kit/workbench-core": "0.0.2-prototype.0.2.43",
40
+ "@workbench-kit/workbench-extension-sdk": "0.0.2-prototype.0.2.43",
41
+ "@workbench-kit/workspace": "0.0.2-prototype.0.2.43",
42
+ "@workbench-kit/workbench-config": "0.0.2-prototype.0.2.43"
42
43
  },
43
44
  "peerDependencies": {
44
45
  "react": "^19.0.0",
@@ -57,6 +58,8 @@
57
58
  },
58
59
  "scripts": {
59
60
  "test": "vitest run --config vitest.config.ts src",
60
- "typecheck": "tsc -p tsconfig.json --noEmit"
61
+ "typecheck": "tsc -p tsconfig.json --noEmit",
62
+ "typecheck:exact-optional": "pnpm --filter @workbench-kit/react exec tsc -p typecheck-exact-optional/tsconfig.emit.json && pnpm typecheck:exact-optional:consumer",
63
+ "typecheck:exact-optional:consumer": "tsc -p typecheck-exact-optional/tsconfig.emit.json && tsc -p typecheck-exact-optional/tsconfig.json --noEmit"
61
64
  }
62
65
  }
@@ -76,7 +76,11 @@ export class ExtensionEnablementController implements DisposableLike {
76
76
  private readonly storage: WorkbenchStorageAdapter | undefined;
77
77
  private readonly storageKey: string;
78
78
  private installedRecords: readonly InstalledExtensionRecord[];
79
- private themeSelectionProtection: ThemeSelectionProtectionSnapshot | undefined;
79
+ private readonly themeSelectionProtectionOwners = new Map<
80
+ number,
81
+ ThemeSelectionProtectionSnapshot
82
+ >();
83
+ private themeSelectionProtectionGeneration = 0;
80
84
  private disposed = false;
81
85
 
82
86
  constructor({
@@ -123,11 +127,27 @@ export class ExtensionEnablementController implements DisposableLike {
123
127
  };
124
128
  };
125
129
 
126
- setThemeSelectionProtection(snapshot: ThemeSelectionProtectionSnapshot | undefined): void {
127
- this.themeSelectionProtection =
128
- snapshot?.kind === 'known'
130
+ setThemeSelectionProtection(snapshot: ThemeSelectionProtectionSnapshot | undefined): () => void {
131
+ const generation = ++this.themeSelectionProtectionGeneration;
132
+ if (snapshot === undefined) {
133
+ this.themeSelectionProtectionOwners.clear();
134
+ return () => undefined;
135
+ }
136
+
137
+ this.themeSelectionProtectionOwners.set(
138
+ generation,
139
+ snapshot.kind === 'known'
129
140
  ? { ...snapshot, protectedThemeIds: [...snapshot.protectedThemeIds] }
130
- : snapshot;
141
+ : snapshot,
142
+ );
143
+ let disposed = false;
144
+ return () => {
145
+ if (disposed) {
146
+ return;
147
+ }
148
+ disposed = true;
149
+ this.themeSelectionProtectionOwners.delete(generation);
150
+ };
131
151
  }
132
152
 
133
153
  commitInstalledRecords(
@@ -228,6 +248,7 @@ export class ExtensionEnablementController implements DisposableLike {
228
248
  this.disposed = true;
229
249
  this.listeners.clear();
230
250
  this.registrationHandles.clear();
251
+ this.themeSelectionProtectionOwners.clear();
231
252
  this.registrationLifetime.dispose();
232
253
  }
233
254
 
@@ -345,15 +366,25 @@ export class ExtensionEnablementController implements DisposableLike {
345
366
  return { commitRequestedState: true, eligible: false, kind: 'reloadRequired' };
346
367
  }
347
368
 
348
- const selectionProtection = this.themeSelectionProtection;
369
+ const selectionProtections = [...this.themeSelectionProtectionOwners.values()];
370
+ const themeRegistryRevision = this.registry.themes.getRevision();
349
371
  if (
350
- selectionProtection?.kind !== 'known' ||
351
- selectionProtection.themeRegistryRevision !== this.registry.themes.getRevision()
372
+ selectionProtections.length === 0 ||
373
+ selectionProtections.some(
374
+ (selectionProtection) =>
375
+ selectionProtection.kind !== 'known' ||
376
+ selectionProtection.themeRegistryRevision !== themeRegistryRevision ||
377
+ !selectionProtection.isCurrent(),
378
+ )
352
379
  ) {
353
380
  return { commitRequestedState: false, eligible: false, kind: 'reloadRequired' };
354
381
  }
355
382
 
356
- const protectedThemeIds = new Set(selectionProtection.protectedThemeIds);
383
+ const protectedThemeIds = new Set(
384
+ selectionProtections.flatMap((selectionProtection) =>
385
+ selectionProtection.kind === 'known' ? selectionProtection.protectedThemeIds : [],
386
+ ),
387
+ );
357
388
 
358
389
  const themes = description.manifest.contributes?.themes ?? [];
359
390
  if (themes.some((theme) => protectedThemeIds.has(theme.id))) {
@@ -1,98 +1,123 @@
1
- import {
2
- DARK_THEME_PRESET_OPTIONS,
3
- LIGHT_THEME_PRESET_OPTIONS,
4
- WORKBENCH_COLOR_SCHEME_OPTIONS,
5
- } from '@workbench-kit/react/workbench';
6
1
  import type { ThemeRegistry } from '@workbench-kit/workbench-core';
7
2
 
8
- interface ThemeOptionLike {
9
- readonly id: string;
3
+ import {
4
+ createWorkbenchAppearanceCatalogSnapshot,
5
+ resolveWorkbenchAppearanceSelection,
6
+ type WorkbenchAppearanceCatalogSnapshot,
7
+ type WorkbenchAppearanceHostOptionInput,
8
+ } from '../shell/appearance-catalog.js';
9
+ import { classifyWorkbenchAppearanceThemeSelection } from '../shell/appearance-presentation.js';
10
+
11
+ interface ThemeSelectionProtectionBase {
12
+ readonly isCurrent: () => boolean;
13
+ readonly sourceFingerprint: string;
14
+ readonly themeRegistryRevision: number;
10
15
  }
11
16
 
12
17
  export type ThemeSelectionProtectionSnapshot =
13
- | {
18
+ | (ThemeSelectionProtectionBase & {
14
19
  readonly kind: 'known';
15
20
  readonly protectedThemeIds: readonly string[];
16
- readonly themeRegistryRevision: number;
17
- }
18
- | {
21
+ })
22
+ | (ThemeSelectionProtectionBase & {
19
23
  readonly kind: 'unknown';
20
- readonly themeRegistryRevision: number;
21
- };
24
+ });
22
25
 
23
26
  export interface ThemeSelectionProtectionInput {
27
+ readonly catalog?: WorkbenchAppearanceCatalogSnapshot | undefined;
24
28
  readonly darkPreset: string | undefined;
25
29
  readonly lightPreset: string | undefined;
26
30
  readonly theme: string | undefined;
27
- readonly themeOptions: readonly ThemeOptionLike[] | undefined;
31
+ readonly themeOptions: readonly WorkbenchAppearanceHostOptionInput[] | undefined;
28
32
  readonly themes: ThemeRegistry;
29
33
  }
30
34
 
31
35
  /**
32
- * Captures only selections that resolve to exactly one current option source.
33
- * Unknown values deliberately do not resolve to the first rendered option: the
34
- * soft lifecycle must fail closed instead of guessing which fallback is active.
36
+ * Captures only selections that resolve to exactly one eligible catalog row. The action guard
37
+ * reconstructs current own data and compares both revision and the canonical source fingerprint,
38
+ * so writable public contributions cannot reuse stale lifecycle protection.
35
39
  */
36
40
  export function createThemeSelectionProtectionSnapshot({
41
+ catalog: suppliedCatalog,
37
42
  darkPreset,
38
43
  lightPreset,
39
44
  theme,
40
45
  themeOptions,
41
46
  themes,
42
47
  }: ThemeSelectionProtectionInput): ThemeSelectionProtectionSnapshot {
43
- const themeRegistryRevision = themes.getRevision();
44
- const contributedThemes = themes.getThemes();
48
+ const catalog =
49
+ suppliedCatalog ??
50
+ createWorkbenchAppearanceCatalogSnapshot({ hostOptions: themeOptions, themes });
51
+ const base: ThemeSelectionProtectionBase = {
52
+ isCurrent: () => isCatalogCurrent(catalog, themes, themeOptions),
53
+ sourceFingerprint: catalog.sourceFingerprint,
54
+ themeRegistryRevision: catalog.themeRegistryRevision,
55
+ };
45
56
  const hasLightPreset = lightPreset !== undefined;
46
57
  const hasDarkPreset = darkPreset !== undefined;
47
-
48
- if (hasLightPreset !== hasDarkPreset) {
49
- return { kind: 'unknown', themeRegistryRevision };
50
- }
58
+ const themeSelection = classifyWorkbenchAppearanceThemeSelection(theme);
51
59
 
52
60
  if (hasLightPreset && hasDarkPreset) {
53
- const lightOptions = [
54
- ...LIGHT_THEME_PRESET_OPTIONS,
55
- ...contributedThemes.filter((candidate) => candidate.mode === 'light'),
56
- ];
57
- const darkOptions = [
58
- ...DARK_THEME_PRESET_OPTIONS,
59
- ...contributedThemes.filter((candidate) => candidate.mode === 'dark'),
60
- ];
61
+ if (themeSelection.kind !== 'base-preference') {
62
+ return Object.freeze({ ...base, kind: 'unknown' });
63
+ }
64
+
65
+ const lightResolution = resolveWorkbenchAppearanceSelection(
66
+ catalog,
67
+ 'light-preset',
68
+ lightPreset,
69
+ );
70
+ const darkResolution = resolveWorkbenchAppearanceSelection(catalog, 'dark-preset', darkPreset);
61
71
 
62
- if (
63
- !hasExactlyOneOption(theme, WORKBENCH_COLOR_SCHEME_OPTIONS) ||
64
- !hasExactlyOneOption(lightPreset, lightOptions) ||
65
- !hasExactlyOneOption(darkPreset, darkOptions)
66
- ) {
67
- return { kind: 'unknown', themeRegistryRevision };
72
+ if (lightResolution.status !== 'resolved' || darkResolution.status !== 'resolved') {
73
+ return Object.freeze({ ...base, kind: 'unknown' });
68
74
  }
69
75
 
70
- return {
76
+ return Object.freeze({
77
+ ...base,
71
78
  kind: 'known',
72
- protectedThemeIds: [theme, lightPreset, darkPreset],
73
- themeRegistryRevision,
74
- };
79
+ protectedThemeIds: Object.freeze([lightPreset, darkPreset]),
80
+ });
75
81
  }
76
82
 
77
- const themeSources = [...(themeOptions ?? []), ...contributedThemes];
78
- if (!hasExactlyOneOption(theme, themeSources)) {
79
- return { kind: 'unknown', themeRegistryRevision };
83
+ if (themeSelection.kind === 'base-preference') {
84
+ return Object.freeze({
85
+ ...base,
86
+ kind: 'known',
87
+ protectedThemeIds: Object.freeze([]),
88
+ });
89
+ }
90
+ const themeResolution = resolveWorkbenchAppearanceSelection(
91
+ catalog,
92
+ 'flat-theme',
93
+ themeSelection.rawTheme,
94
+ );
95
+ if (themeResolution.status !== 'resolved') {
96
+ return Object.freeze({ ...base, kind: 'unknown' });
80
97
  }
81
98
 
82
- return {
99
+ return Object.freeze({
100
+ ...base,
83
101
  kind: 'known',
84
- protectedThemeIds: [theme],
85
- themeRegistryRevision,
86
- };
102
+ protectedThemeIds: Object.freeze([themeSelection.rawTheme]),
103
+ });
87
104
  }
88
105
 
89
- function hasExactlyOneOption(
90
- selection: string | undefined,
91
- options: readonly ThemeOptionLike[],
92
- ): selection is string {
93
- if (!selection) {
106
+ function isCatalogCurrent(
107
+ captured: WorkbenchAppearanceCatalogSnapshot,
108
+ themes: ThemeRegistry,
109
+ themeOptions: readonly WorkbenchAppearanceHostOptionInput[] | undefined,
110
+ ): boolean {
111
+ try {
112
+ const current = createWorkbenchAppearanceCatalogSnapshot({
113
+ hostOptions: themeOptions,
114
+ themes,
115
+ });
116
+ return (
117
+ current.themeRegistryRevision === captured.themeRegistryRevision &&
118
+ current.sourceFingerprint === captured.sourceFingerprint
119
+ );
120
+ } catch {
94
121
  return false;
95
122
  }
96
-
97
- return options.filter((option) => option.id === selection).length === 1;
98
123
  }
@@ -37,6 +37,12 @@ export interface FieldRemapChromeLabels {
37
37
  readonly hideHiddenFields: string;
38
38
  readonly emptyDetailTitle: string;
39
39
  readonly emptyDetailDescription: string;
40
+ /** Additive inspection-only empty-detail copy. Omitted hosts use the English defaults. */
41
+ readonly readOnlyEmptyDetailTitle?: string;
42
+ readonly readOnlyEmptyDetailDescription?: string;
43
+ /** Additive selection-detail Modal copy. Omitted legacy label objects use English defaults. */
44
+ readonly detailModalTitle?: string;
45
+ readonly closeDetailModal?: string;
40
46
  /** Additive Flow preview copy. Omitted legacy label objects use English defaults. */
41
47
  readonly previewTitle?: string;
42
48
  readonly previewLoading?: string;
@@ -76,6 +82,10 @@ export const defaultFieldRemapChromeLabels = {
76
82
  emptyDetailTitle: 'Start with a convert',
77
83
  emptyDetailDescription:
78
84
  'Use the Convert palette to place a convert, then wire source → draft → target. Or select an existing binding / convert note on the canvas.',
85
+ readOnlyEmptyDetailTitle: 'Inspect mappings',
86
+ readOnlyEmptyDetailDescription: 'Select a mapping to inspect its details.',
87
+ detailModalTitle: 'Mapping details',
88
+ closeDetailModal: 'Close details',
79
89
  previewTitle: 'Sample preview',
80
90
  previewLoading: 'Updating preview…',
81
91
  previewError: 'Preview failed',
@@ -114,6 +124,10 @@ export const fieldRemapChromeLabelKeys = {
114
124
  hideHiddenFields: 'fieldRemap.hideHiddenFields',
115
125
  emptyDetailTitle: 'fieldRemap.emptyDetailTitle',
116
126
  emptyDetailDescription: 'fieldRemap.emptyDetailDescription',
127
+ readOnlyEmptyDetailTitle: 'fieldRemap.readOnlyEmptyDetailTitle',
128
+ readOnlyEmptyDetailDescription: 'fieldRemap.readOnlyEmptyDetailDescription',
129
+ detailModalTitle: 'fieldRemap.detailModalTitle',
130
+ closeDetailModal: 'fieldRemap.closeDetailModal',
117
131
  previewTitle: 'fieldRemap.previewTitle',
118
132
  previewLoading: 'fieldRemap.previewLoading',
119
133
  previewError: 'fieldRemap.previewError',
@@ -162,6 +176,10 @@ export function resolveFieldRemapChromeLabels(
162
176
  hideHiddenFields: resolve('hideHiddenFields'),
163
177
  emptyDetailTitle: resolve('emptyDetailTitle'),
164
178
  emptyDetailDescription: resolve('emptyDetailDescription'),
179
+ readOnlyEmptyDetailTitle: resolve('readOnlyEmptyDetailTitle'),
180
+ readOnlyEmptyDetailDescription: resolve('readOnlyEmptyDetailDescription'),
181
+ detailModalTitle: resolve('detailModalTitle'),
182
+ closeDetailModal: resolve('closeDetailModal'),
165
183
  previewTitle: resolve('previewTitle'),
166
184
  previewLoading: resolve('previewLoading'),
167
185
  previewError: resolve('previewError'),
@@ -28,6 +28,7 @@ export interface ConvertNoteEditorProps {
28
28
  readonly onEdgesChange: (edges: readonly MappingEdge[]) => void;
29
29
  readonly edges: readonly MappingEdge[];
30
30
  readonly onSelectionChange: (next: FieldRemapSelection) => void;
31
+ readonly readOnly?: boolean | undefined;
31
32
  }
32
33
 
33
34
  /**
@@ -43,6 +44,7 @@ export function ConvertNoteEditor({
43
44
  edges,
44
45
  onEdgesChange,
45
46
  onSelectionChange,
47
+ readOnly = false,
46
48
  }: ConvertNoteEditorProps): JSX.Element | null {
47
49
  const chain = edge.transformIds ?? [];
48
50
  const transformId = chain[stepIndex];
@@ -77,7 +79,7 @@ export function ConvertNoteEditor({
77
79
  const stepLabel = definition?.label ?? transformId;
78
80
 
79
81
  const applyEdge = (next: MappingEdge | null) => {
80
- if (!next) {
82
+ if (readOnly || !next) {
81
83
  return;
82
84
  }
83
85
  onEdgesChange(edges.map((item) => (item.id === edge.id ? next : item)));
@@ -135,6 +137,7 @@ export function ConvertNoteEditor({
135
137
  aria-label="Convert transform id"
136
138
  data-testid="field-remap-step-id"
137
139
  value={transformId}
140
+ disabled={readOnly}
138
141
  onChange={(event) => {
139
142
  applyEdge(
140
143
  setTransformStepIdOnEdge(edge, stepIndex, event.target.value, {
@@ -165,35 +168,38 @@ export function ConvertNoteEditor({
165
168
  <TransformOptionsEditor
166
169
  fields={optionFields}
167
170
  value={optionValue}
171
+ disabled={readOnly}
168
172
  onChange={(nextOptions) => {
169
173
  applyEdge(replaceTransformStepOptionsOnEdge(edge, stepIndex, nextOptions));
170
174
  }}
171
175
  />
172
176
  </section>
173
177
 
174
- <div className="workbench-field-remap-convert-note__footer">
175
- <Button
176
- compact
177
- type="button"
178
- data-testid="field-remap-convert-note-remove"
179
- onClick={() => {
180
- const next = removeTransformStepFromEdge(edge, stepIndex);
181
- applyEdge(next);
182
- const nextLen = next.transformIds?.length ?? 0;
183
- if (nextLen === 0) {
184
- onSelectionChange({ kind: 'edge', edgeId: edge.id });
185
- } else {
186
- onSelectionChange({
187
- kind: 'transformStep',
188
- edgeId: edge.id,
189
- stepIndex: Math.min(stepIndex, nextLen - 1),
190
- });
191
- }
192
- }}
193
- >
194
- Remove convert
195
- </Button>
196
- </div>
178
+ {!readOnly ? (
179
+ <div className="workbench-field-remap-convert-note__footer">
180
+ <Button
181
+ compact
182
+ type="button"
183
+ data-testid="field-remap-convert-note-remove"
184
+ onClick={() => {
185
+ const next = removeTransformStepFromEdge(edge, stepIndex);
186
+ applyEdge(next);
187
+ const nextLen = next.transformIds?.length ?? 0;
188
+ if (nextLen === 0) {
189
+ onSelectionChange({ kind: 'edge', edgeId: edge.id });
190
+ } else {
191
+ onSelectionChange({
192
+ kind: 'transformStep',
193
+ edgeId: edge.id,
194
+ stepIndex: Math.min(stepIndex, nextLen - 1),
195
+ });
196
+ }
197
+ }}
198
+ >
199
+ Remove convert
200
+ </Button>
201
+ </div>
202
+ ) : null}
197
203
  </aside>
198
204
  );
199
205
  }
@@ -4,6 +4,7 @@ import { ClearableTextInput, IconButton } from '@workbench-kit/react/primitives'
4
4
  import type { ValueTransformRegistry } from '@workbench-kit/field-remap';
5
5
 
6
6
  import { defaultFieldRemapChromeLabels, type FieldRemapChromeLabels } from './chrome-labels.js';
7
+ import { writeFieldRemapTransformDragData } from './drag-payload.js';
7
8
 
8
9
  export interface FieldRemapConvertPaletteProps {
9
10
  readonly transforms: ValueTransformRegistry;
@@ -207,6 +208,7 @@ export function FieldRemapConvertPalette({
207
208
  key={definition.id}
208
209
  aria-selected={selected}
209
210
  data-testid={`field-remap-palette-item-${definition.id}`}
211
+ draggable
210
212
  role="option"
211
213
  selected={selected}
212
214
  tabIndex={definition.id === rovingTransformId ? 0 : -1}
@@ -217,6 +219,10 @@ export function FieldRemapConvertPalette({
217
219
  onSelectedTransformIdChange(definition.id);
218
220
  }}
219
221
  onDoubleClick={() => onPlaceDraft(definition.id)}
222
+ onDragStart={(event) => {
223
+ event.dataTransfer.effectAllowed = 'copy';
224
+ writeFieldRemapTransformDragData(event.dataTransfer, definition.id);
225
+ }}
220
226
  onFocus={() => setRequestedRovingTransformId(definition.id)}
221
227
  onKeyDown={(event) => handleOptionKeyDown(event, definition.id)}
222
228
  >
@@ -25,6 +25,8 @@ export interface SampleFieldRemapDemoProps {
25
25
  readonly showBindingsList?: FieldRemapFlowMapperProps['showBindingsList'];
26
26
  readonly showConvertPalette?: FieldRemapFlowMapperProps['showConvertPalette'];
27
27
  readonly emptyDetail?: FieldRemapFlowMapperProps['emptyDetail'];
28
+ readonly detailPresentation?: FieldRemapFlowMapperProps['detailPresentation'];
29
+ readonly readOnly?: FieldRemapFlowMapperProps['readOnly'];
28
30
  /** When true, show host-chrome demo controls (history + fit view). */
29
31
  readonly showHostChromeDemo?: boolean | undefined;
30
32
  /** Prefer `browse` for I/O class/field inspection demos. */
@@ -46,6 +48,8 @@ export function SampleFieldRemapDemo({
46
48
  showBindingsList,
47
49
  showConvertPalette,
48
50
  emptyDetail,
51
+ detailPresentation,
52
+ readOnly,
49
53
  showHostChromeDemo = false,
50
54
  ioChrome,
51
55
  browseSeedShapes = false,
@@ -89,7 +93,7 @@ export function SampleFieldRemapDemo({
89
93
  compact
90
94
  type="button"
91
95
  data-testid="field-remap-undo"
92
- disabled={!historyAvailability.canUndo}
96
+ disabled={readOnly || !historyAvailability.canUndo}
93
97
  onClick={() => historyActionsRef.current?.undo()}
94
98
  >
95
99
  Undo
@@ -98,7 +102,7 @@ export function SampleFieldRemapDemo({
98
102
  compact
99
103
  type="button"
100
104
  data-testid="field-remap-redo"
101
- disabled={!historyAvailability.canRedo}
105
+ disabled={readOnly || !historyAvailability.canRedo}
102
106
  onClick={() => historyActionsRef.current?.redo()}
103
107
  >
104
108
  Redo
@@ -119,6 +123,8 @@ export function SampleFieldRemapDemo({
119
123
  showBindingsList={showBindingsList}
120
124
  showConvertPalette={showConvertPalette}
121
125
  emptyDetail={emptyDetail}
126
+ detailPresentation={detailPresentation}
127
+ readOnly={readOnly}
122
128
  onShowMinimapChange={setShowMinimap}
123
129
  ioChrome={ioChrome}
124
130
  editableShapes={ioChrome === 'browse' ? false : undefined}