@contentful/experience-design-system-cli 2.14.2 → 2.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (193) hide show
  1. package/README.md +64 -2
  2. package/dist/package.json +3 -2
  3. package/dist/src/analyze/command.d.ts +11 -0
  4. package/dist/src/analyze/command.js +298 -3
  5. package/dist/src/analyze/composite-closure.d.ts +25 -0
  6. package/dist/src/analyze/composite-closure.js +0 -0
  7. package/dist/src/analyze/composition/agent-parser/author-prompt.d.ts +4 -0
  8. package/dist/src/analyze/composition/agent-parser/author-prompt.js +22 -0
  9. package/dist/src/analyze/composition/agent-parser/extract-parser.d.ts +8 -0
  10. package/dist/src/analyze/composition/agent-parser/extract-parser.js +44 -0
  11. package/dist/src/analyze/composition/agent-parser/load-prompt.d.ts +7 -0
  12. package/dist/src/analyze/composition/agent-parser/load-prompt.js +31 -0
  13. package/dist/src/analyze/composition/agent-parser/resolve-via-parser.d.ts +46 -0
  14. package/dist/src/analyze/composition/agent-parser/resolve-via-parser.js +88 -0
  15. package/dist/src/analyze/composition/agent-parser/sandbox.d.ts +45 -0
  16. package/dist/src/analyze/composition/agent-parser/sandbox.js +187 -0
  17. package/dist/src/analyze/composition/apply-mapping.d.ts +22 -0
  18. package/dist/src/analyze/composition/apply-mapping.js +67 -0
  19. package/dist/src/analyze/composition/candidate-critic-agent.d.ts +7 -0
  20. package/dist/src/analyze/composition/candidate-critic-agent.js +39 -0
  21. package/dist/src/analyze/composition/candidate-critic.d.ts +32 -0
  22. package/dist/src/analyze/composition/candidate-critic.js +63 -0
  23. package/dist/src/analyze/composition/candidate-files.d.ts +34 -0
  24. package/dist/src/analyze/composition/candidate-files.js +131 -0
  25. package/dist/src/analyze/composition/composition-cache-key.d.ts +19 -0
  26. package/dist/src/analyze/composition/composition-cache-key.js +19 -0
  27. package/dist/src/analyze/composition/interchange-schema.d.ts +37 -0
  28. package/dist/src/analyze/composition/interchange-schema.js +61 -0
  29. package/dist/src/analyze/composition/merge-edges.d.ts +19 -0
  30. package/dist/src/analyze/composition/merge-edges.js +50 -0
  31. package/dist/src/analyze/composition/parse-map-edges.d.ts +16 -0
  32. package/dist/src/analyze/composition/parse-map-edges.js +55 -0
  33. package/dist/src/analyze/composition/resolve-mapping-cli.d.ts +23 -0
  34. package/dist/src/analyze/composition/resolve-mapping-cli.js +29 -0
  35. package/dist/src/analyze/composition/resolve-mapping.d.ts +59 -0
  36. package/dist/src/analyze/composition/resolve-mapping.js +94 -0
  37. package/dist/src/analyze/cycle-detection.d.ts +27 -0
  38. package/dist/src/analyze/cycle-detection.js +234 -0
  39. package/dist/src/analyze/cycle-view.d.ts +7 -0
  40. package/dist/src/analyze/cycle-view.js +28 -0
  41. package/dist/src/analyze/fuzzy-search.d.ts +3 -0
  42. package/dist/src/analyze/fuzzy-search.js +55 -0
  43. package/dist/src/analyze/issue-inheritance.d.ts +12 -0
  44. package/dist/src/analyze/issue-inheritance.js +108 -0
  45. package/dist/src/analyze/lineage.d.ts +29 -0
  46. package/dist/src/analyze/lineage.js +0 -0
  47. package/dist/src/analyze/scope-gate-cascade.d.ts +11 -0
  48. package/dist/src/analyze/scope-gate-cascade.js +170 -0
  49. package/dist/src/analyze/search-neighborhood.d.ts +3 -0
  50. package/dist/src/analyze/search-neighborhood.js +56 -0
  51. package/dist/src/analyze/select/tui/App.js +5 -16
  52. package/dist/src/analyze/select/tui/components/ComponentRationalePanel.d.ts +0 -4
  53. package/dist/src/analyze/select/tui/components/ComponentRationalePanel.js +3 -15
  54. package/dist/src/analyze/select/tui/components/FieldEditor.d.ts +29 -38
  55. package/dist/src/analyze/select/tui/components/FieldEditor.js +309 -185
  56. package/dist/src/analyze/select/tui/components/FinalizeDialog.d.ts +13 -1
  57. package/dist/src/analyze/select/tui/components/FinalizeDialog.js +23 -2
  58. package/dist/src/analyze/select/tui/components/GotoBanner.d.ts +31 -0
  59. package/dist/src/analyze/select/tui/components/GotoBanner.js +30 -0
  60. package/dist/src/analyze/select/tui/components/GroupedSidebar.d.ts +91 -0
  61. package/dist/src/analyze/select/tui/components/GroupedSidebar.js +497 -0
  62. package/dist/src/analyze/select/tui/components/HelpOverlay.d.ts +13 -1
  63. package/dist/src/analyze/select/tui/components/HelpOverlay.js +9 -3
  64. package/dist/src/analyze/select/tui/components/JsonEditor.js +3 -3
  65. package/dist/src/analyze/select/tui/components/LineagePanel.d.ts +14 -0
  66. package/dist/src/analyze/select/tui/components/LineagePanel.js +22 -0
  67. package/dist/src/analyze/select/tui/components/RationalePanel.d.ts +0 -7
  68. package/dist/src/analyze/select/tui/components/RationalePanel.js +3 -15
  69. package/dist/src/analyze/select/tui/components/Sidebar.d.ts +0 -6
  70. package/dist/src/analyze/select/tui/components/Sidebar.js +11 -27
  71. package/dist/src/analyze/select/tui/components/StatusBar.js +2 -1
  72. package/dist/src/analyze/select/tui/components/removed-components-text.d.ts +7 -0
  73. package/dist/src/analyze/select/tui/components/removed-components-text.js +11 -0
  74. package/dist/src/analyze/select/tui/hooks/useImmediateInput.d.ts +1 -5
  75. package/dist/src/analyze/select/tui/hooks/useImmediateInput.js +8 -10
  76. package/dist/src/analyze/select/tui/theme.d.ts +15 -0
  77. package/dist/src/analyze/select/tui/theme.js +28 -0
  78. package/dist/src/analyze/selection-cascade.d.ts +3 -0
  79. package/dist/src/analyze/selection-cascade.js +19 -0
  80. package/dist/src/analyze/slot-graph.d.ts +11 -0
  81. package/dist/src/analyze/slot-graph.js +17 -0
  82. package/dist/src/apply/api-client.d.ts +2 -1
  83. package/dist/src/apply/api-client.js +40 -6
  84. package/dist/src/apply/command.d.ts +17 -1
  85. package/dist/src/apply/command.js +69 -11
  86. package/dist/src/apply/error-parser.d.ts +16 -0
  87. package/dist/src/apply/error-parser.js +117 -0
  88. package/dist/src/apply/tui/ServerPreviewView.js +3 -1
  89. package/dist/src/credentials-store.d.ts +6 -0
  90. package/dist/src/credentials-store.js +5 -1
  91. package/dist/src/generate/agent-runner.d.ts +6 -0
  92. package/dist/src/generate/agent-runner.js +24 -8
  93. package/dist/src/import/command.js +69 -31
  94. package/dist/src/import/cycle-auto-reject.d.ts +4 -0
  95. package/dist/src/import/cycle-auto-reject.js +14 -0
  96. package/dist/src/import/orchestrator.d.ts +20 -7
  97. package/dist/src/import/orchestrator.js +139 -35
  98. package/dist/src/import/strip-allowed-components.d.ts +18 -0
  99. package/dist/src/import/strip-allowed-components.js +22 -0
  100. package/dist/src/import/tui/CustomPromptBanner.d.ts +0 -5
  101. package/dist/src/import/tui/CustomPromptBanner.js +2 -1
  102. package/dist/src/import/tui/WizardApp.d.ts +10 -74
  103. package/dist/src/import/tui/WizardApp.js +234 -246
  104. package/dist/src/import/tui/ai-flag.d.ts +5 -0
  105. package/dist/src/import/tui/ai-flag.js +6 -0
  106. package/dist/src/import/tui/autocomplete.d.ts +5 -0
  107. package/dist/src/import/tui/autocomplete.js +32 -0
  108. package/dist/src/import/tui/components/AutoFilterBanner.d.ts +10 -0
  109. package/dist/src/import/tui/components/AutoFilterBanner.js +19 -0
  110. package/dist/src/import/tui/components/CounterStrip.d.ts +12 -0
  111. package/dist/src/import/tui/components/CounterStrip.js +12 -0
  112. package/dist/src/import/tui/components/LegendEntry.d.ts +2 -0
  113. package/dist/src/import/tui/components/LegendEntry.js +6 -0
  114. package/dist/src/import/tui/cycle-panel-scroll.d.ts +8 -0
  115. package/dist/src/import/tui/cycle-panel-scroll.js +33 -0
  116. package/dist/src/import/tui/final-review-host.d.ts +3 -7
  117. package/dist/src/import/tui/final-review-host.js +9 -4
  118. package/dist/src/import/tui/group-collapse.d.ts +2 -0
  119. package/dist/src/import/tui/group-collapse.js +11 -0
  120. package/dist/src/import/tui/history.d.ts +22 -0
  121. package/dist/src/import/tui/history.js +53 -0
  122. package/dist/src/import/tui/hooks/useLineage.d.ts +32 -0
  123. package/dist/src/import/tui/hooks/useLineage.js +50 -0
  124. package/dist/src/import/tui/hooks/useOverlayPanel.d.ts +13 -0
  125. package/dist/src/import/tui/hooks/useOverlayPanel.js +27 -0
  126. package/dist/src/import/tui/lineage-layout.d.ts +75 -0
  127. package/dist/src/import/tui/lineage-layout.js +82 -0
  128. package/dist/src/import/tui/runLivePreview.d.ts +11 -0
  129. package/dist/src/import/tui/runLivePreview.js +7 -1
  130. package/dist/src/import/tui/scope-gate-columns.d.ts +31 -0
  131. package/dist/src/import/tui/scope-gate-columns.js +94 -0
  132. package/dist/src/import/tui/scope-gate-host.d.ts +3 -1
  133. package/dist/src/import/tui/scope-gate-host.js +11 -3
  134. package/dist/src/import/tui/sidebar-width.d.ts +1 -0
  135. package/dist/src/import/tui/sidebar-width.js +3 -0
  136. package/dist/src/import/tui/step-filters.d.ts +19 -0
  137. package/dist/src/import/tui/step-filters.js +42 -0
  138. package/dist/src/import/tui/steps/AtomicGenerateReviewStep.d.ts +42 -0
  139. package/dist/src/import/tui/steps/AtomicGenerateReviewStep.js +726 -0
  140. package/dist/src/import/tui/steps/AtomicScopeGateStep.d.ts +3 -0
  141. package/dist/src/import/tui/steps/AtomicScopeGateStep.js +211 -0
  142. package/dist/src/import/tui/steps/CredentialsStep.js +5 -18
  143. package/dist/src/import/tui/steps/DoneStep.d.ts +0 -2
  144. package/dist/src/import/tui/steps/DoneStep.js +3 -2
  145. package/dist/src/import/tui/steps/ErrorStep.js +2 -1
  146. package/dist/src/import/tui/steps/GenerateReviewStep.d.ts +19 -25
  147. package/dist/src/import/tui/steps/GenerateReviewStep.js +1258 -229
  148. package/dist/src/import/tui/steps/PathValidationStep.js +3 -2
  149. package/dist/src/import/tui/steps/PreviewStep.js +2 -1
  150. package/dist/src/import/tui/steps/PushDecisionGateStep.d.ts +0 -7
  151. package/dist/src/import/tui/steps/PushDecisionGateStep.js +3 -6
  152. package/dist/src/import/tui/steps/PushingStep.js +2 -1
  153. package/dist/src/import/tui/steps/RunningStep.d.ts +4 -1
  154. package/dist/src/import/tui/steps/RunningStep.js +3 -2
  155. package/dist/src/import/tui/steps/ScopeGateStep.d.ts +24 -0
  156. package/dist/src/import/tui/steps/ScopeGateStep.js +850 -193
  157. package/dist/src/import/tui/steps/TokenInputStep.js +2 -1
  158. package/dist/src/import/tui/steps/WelcomeStep.js +2 -1
  159. package/dist/src/import/tui/steps/WizardPreviewStep.d.ts +6 -0
  160. package/dist/src/import/tui/steps/WizardPreviewStep.js +95 -56
  161. package/dist/src/import/tui/steps/auto-reject-decision.d.ts +7 -0
  162. package/dist/src/import/tui/steps/auto-reject-decision.js +9 -0
  163. package/dist/src/import/tui/steps/breaking-change-format.d.ts +2 -0
  164. package/dist/src/import/tui/steps/breaking-change-format.js +24 -0
  165. package/dist/src/import/tui/steps/enumerate-cycle-breaks.d.ts +14 -0
  166. package/dist/src/import/tui/steps/enumerate-cycle-breaks.js +0 -0
  167. package/dist/src/import/tui/steps/preview-diff.js +48 -5
  168. package/dist/src/import/tui/useFinalizePreview.d.ts +34 -0
  169. package/dist/src/import/tui/useFinalizePreview.js +0 -0
  170. package/dist/src/import/tui/useLivePreview.d.ts +3 -0
  171. package/dist/src/import/tui/useLivePreview.js +1 -0
  172. package/dist/src/import/tui/wizard-state-transitions.d.ts +5 -59
  173. package/dist/src/import/tui/wizard-state-transitions.js +5 -60
  174. package/dist/src/lib/composition-mode.d.ts +28 -0
  175. package/dist/src/lib/composition-mode.js +37 -0
  176. package/dist/src/lib/prompt-overrides.d.ts +38 -0
  177. package/dist/src/lib/prompt-overrides.js +69 -0
  178. package/dist/src/print/command.js +19 -1
  179. package/dist/src/runs/modify-launcher.d.ts +3 -0
  180. package/dist/src/runs/modify-launcher.js +2 -0
  181. package/dist/src/runs/replay-helpers.js +1 -0
  182. package/dist/src/runs/run-picker.js +2 -18
  183. package/dist/src/runs/store.d.ts +4 -0
  184. package/dist/src/session/db.d.ts +23 -40
  185. package/dist/src/session/db.js +110 -138
  186. package/dist/src/setup/composition-mode-prompt.d.ts +14 -0
  187. package/dist/src/setup/composition-mode-prompt.js +37 -0
  188. package/package.json +5 -4
  189. package/prompts/composition-dir-critic.md +7 -0
  190. package/prompts/composition-edges.md +8 -0
  191. package/prompts/composition-parser-repair-empty.md +1 -0
  192. package/prompts/composition-parser-repair-error.md +2 -0
  193. package/prompts/composition-parser.md +42 -0
@@ -1,14 +1,11 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import React, { useState } from 'react';
3
+ import { PALETTE } from '../theme.js';
3
4
  import { Box, Text } from 'ink';
4
5
  import { CDF_PROPERTY_TYPES, CDF_PROPERTY_CATEGORIES, DESIGN_TOKEN_TYPES, } from '@contentful/experience-design-system-types';
5
6
  import { useImmediateInput } from '../hooks/useImmediateInput.js';
6
7
  import { computeNextScrollOffset } from '../hooks/scroll-offset.js';
7
- // Section indices for top-level navigation
8
- // 0 = component $description row
9
- // 1 = $properties header (then props[0..n-1])
10
- // 2 = $slots header (then slots[0..m-1])
11
- // ── Parsing helpers ───────────────────────────────────────────────────────────
8
+ import { findSlotCycles } from '../../../cycle-detection.js';
12
9
  function parseToState(json) {
13
10
  let parsed;
14
11
  try {
@@ -24,7 +21,6 @@ function parseToState(json) {
24
21
  };
25
22
  }
26
23
  const entry = parsed;
27
- // Handle both bare CDFComponentEntry and wrapped { [name]: entry } forms
28
24
  let component;
29
25
  const keys = Object.keys(entry);
30
26
  if (entry.$type === 'component' || entry.$properties !== undefined) {
@@ -47,8 +43,6 @@ function parseToState(json) {
47
43
  defaultValue = typeof p.$default === 'boolean' ? p.$default : null;
48
44
  }
49
45
  else {
50
- // Store as string for string/number/token/enum. Numbers/JSON values
51
- // are stringified — downstream serialization re-emits the string.
52
46
  defaultValue = typeof p.$default === 'string' ? p.$default : String(p.$default);
53
47
  }
54
48
  }
@@ -80,7 +74,6 @@ function parseToState(json) {
80
74
  return { state: { componentDescription, props, slots }, error: null };
81
75
  }
82
76
  function serializeState(state, originalJson) {
83
- // Preserve the wrapper key if the original was wrapped
84
77
  let wrapperKey = null;
85
78
  try {
86
79
  const orig = JSON.parse(originalJson);
@@ -92,9 +85,7 @@ function serializeState(state, originalJson) {
92
85
  }
93
86
  }
94
87
  }
95
- catch {
96
- // ignore
97
- }
88
+ catch { }
98
89
  const $properties = {};
99
90
  for (const p of state.props) {
100
91
  const def = {
@@ -109,9 +100,6 @@ function serializeState(state, originalJson) {
109
100
  def.$values = p.values;
110
101
  if (p.type === 'token' && p.tokenKind)
111
102
  def['$token.kind'] = p.tokenKind;
112
- // $default — gated per type so e.g. boolean props don't get string defaults.
113
- // Empty string == unset for text-typed defaults. richtext/media/link don't
114
- // emit a default (defaults aren't meaningful for those types).
115
103
  if (p.default !== null) {
116
104
  if (p.type === 'boolean' && typeof p.default === 'boolean') {
117
105
  def.$default = p.default;
@@ -148,47 +136,40 @@ function serializeState(state, originalJson) {
148
136
  }
149
137
  return JSON.stringify(entry, null, 2);
150
138
  }
151
- // ── Sub-components ────────────────────────────────────────────────────────────
152
139
  function Picker({ value, active }) {
153
- return (_jsxs(Box, { children: [active && _jsx(Text, { color: "cyan", children: '‹' }), _jsx(Text, { color: active ? 'cyan' : 'white', bold: active, children: value }), active && _jsx(Text, { color: "cyan", children: '›' })] }));
140
+ return (_jsxs(Box, { children: [active && _jsx(Text, { color: PALETTE.info, children: '‹' }), _jsx(Text, { color: active ? PALETTE.info : PALETTE.inverse, bold: active, children: value }), active && _jsx(Text, { color: PALETTE.info, children: '›' })] }));
154
141
  }
155
142
  function Toggle({ value, active }) {
156
- return (_jsx(Box, { children: _jsx(Text, { color: active ? 'cyan' : value ? 'green' : undefined, children: value ? '[✓]' : '[ ]' }) }));
143
+ return (_jsx(Box, { children: _jsx(Text, { color: active ? PALETTE.info : value ? PALETTE.success : undefined, children: value ? '[✓]' : '[ ]' }) }));
157
144
  }
158
145
  function DefaultSubRow({ prop, active, textCursor, cursorVisible, }) {
159
146
  const cursor = cursorVisible ? '█' : ' ';
160
- // richtext/media/link don't support defaults — render an inert dim line.
161
147
  if (prop.type === 'richtext' || prop.type === 'media' || prop.type === 'link') {
162
148
  return (_jsxs(Box, { paddingLeft: 2, gap: 1, children: [_jsx(Text, { dimColor: true, children: "default:" }), _jsx(Text, { dimColor: true, children: "(not applicable)" })] }));
163
149
  }
164
- // boolean — tri-state picker: true | false | (unset).
165
150
  if (prop.type === 'boolean') {
166
151
  const display = prop.default === true ? 'true' : prop.default === false ? 'false' : '(unset)';
167
- return (_jsxs(Box, { paddingLeft: 2, gap: 1, children: [_jsx(Text, { dimColor: true, children: "default:" }), active ? _jsx(Picker, { value: display, active: true }) : _jsx(Text, { color: "white", children: display })] }));
152
+ return (_jsxs(Box, { paddingLeft: 2, gap: 1, children: [_jsx(Text, { dimColor: true, children: "default:" }), active ? _jsx(Picker, { value: display, active: true }) : _jsx(Text, { color: PALETTE.inverse, children: display })] }));
168
153
  }
169
- // enum — picker over prop.values plus (unset).
170
154
  if (prop.type === 'enum') {
171
155
  if (prop.values.length === 0) {
172
156
  return (_jsxs(Box, { paddingLeft: 2, gap: 1, children: [_jsx(Text, { dimColor: true, children: "default:" }), _jsx(Text, { dimColor: true, children: "(no values defined)" })] }));
173
157
  }
174
158
  const display = typeof prop.default === 'string' && prop.default !== '' ? prop.default : '(unset)';
175
- return (_jsxs(Box, { paddingLeft: 2, gap: 1, children: [_jsx(Text, { dimColor: true, children: "default:" }), active ? _jsx(Picker, { value: display, active: true }) : _jsx(Text, { color: "white", children: display })] }));
159
+ return (_jsxs(Box, { paddingLeft: 2, gap: 1, children: [_jsx(Text, { dimColor: true, children: "default:" }), active ? _jsx(Picker, { value: display, active: true }) : _jsx(Text, { color: PALETTE.inverse, children: display })] }));
176
160
  }
177
- // string / number / token — text input. Active state shows bordered cyan
178
- // box analogous to the description editor.
179
161
  const value = typeof prop.default === 'string' ? prop.default : '';
180
162
  if (active) {
181
- return (_jsxs(Box, { paddingLeft: 2, flexDirection: "row", children: [_jsx(Text, { dimColor: true, children: "default:" }), _jsxs(Box, { flexGrow: 1, borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsx(Text, { children: value.slice(0, textCursor) }), _jsx(Text, { inverse: cursorVisible, children: value[textCursor] ?? cursor }), _jsx(Text, { children: value.slice(textCursor + 1) })] })] }));
163
+ return (_jsxs(Box, { paddingLeft: 2, flexDirection: "row", children: [_jsx(Text, { dimColor: true, children: "default:" }), _jsxs(Box, { flexGrow: 1, borderStyle: "round", borderColor: PALETTE.info, paddingX: 1, children: [_jsx(Text, { children: value.slice(0, textCursor) }), _jsx(Text, { inverse: cursorVisible, children: value[textCursor] ?? cursor }), _jsx(Text, { children: value.slice(textCursor + 1) })] })] }));
182
164
  }
183
- return (_jsxs(Box, { paddingLeft: 2, gap: 1, children: [_jsx(Text, { dimColor: true, children: "default:" }), _jsx(Text, { color: value ? 'white' : undefined, dimColor: !value, children: value || '(none)' })] }));
165
+ return (_jsxs(Box, { paddingLeft: 2, gap: 1, children: [_jsx(Text, { dimColor: true, children: "default:" }), _jsx(Text, { color: value ? PALETTE.inverse : undefined, dimColor: !value, children: value || '(none)' })] }));
184
166
  }
185
167
  function PropRow({ prop, selected, activeField, textCursor, valueCursor, cursorVisible, editingValue, valueText, width, rationale, rowKey, }) {
186
168
  const cursor = cursorVisible ? '█' : ' ';
187
169
  const bg = selected ? 'blue' : undefined;
188
170
  const descActive = activeField === 'description';
189
- // Name column — fixed 14 chars
190
171
  const nameDisplay = prop.name.length > 14 ? prop.name.slice(0, 13) + '…' : prop.name.padEnd(14);
191
- return (_jsxs(Box, { flexDirection: "column", width: width, children: [_jsxs(Box, { gap: 1, children: [_jsxs(Text, { color: selected ? 'white' : 'cyan', bold: selected, backgroundColor: bg, children: [' ', nameDisplay, ' '] }), _jsx(Text, { dimColor: !selected, children: "type:" }), activeField === 'type' ? (_jsx(Picker, { value: prop.type, active: true })) : (_jsx(Text, { color: selected ? 'yellow' : 'white', children: prop.type })), _jsx(Text, { dimColor: !selected, children: "cat:" }), activeField === 'category' ? (_jsx(Picker, { value: prop.category, active: true })) : (_jsx(Text, { color: selected ? 'magenta' : 'white', children: prop.category })), _jsx(Text, { dimColor: !selected, children: "req:" }), activeField === 'required' ? (_jsx(Toggle, { value: prop.required, active: true })) : (_jsx(Toggle, { value: prop.required, active: false })), prop.type === 'token' && (_jsxs(_Fragment, { children: [_jsx(Text, { dimColor: !selected, children: "kind:" }), activeField === 'tokenKind' ? (_jsx(Picker, { value: prop.tokenKind || DESIGN_TOKEN_TYPES[0], active: true })) : (_jsx(Text, { color: selected ? 'green' : 'white', children: prop.tokenKind || '—' }))] }))] }), selected && (_jsx(DefaultSubRow, { prop: prop, active: activeField === 'default', textCursor: textCursor, cursorVisible: cursorVisible })), selected && descActive && (_jsxs(Box, { paddingLeft: 2, flexDirection: "row", children: [_jsx(Text, { dimColor: true, children: "desc:" }), _jsxs(Box, { flexGrow: 1, borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsx(Text, { children: prop.description.slice(0, textCursor) }), _jsx(Text, { inverse: cursorVisible, children: prop.description[textCursor] ?? cursor }), _jsx(Text, { children: prop.description.slice(textCursor + 1) })] })] })), selected && !descActive && (_jsxs(Box, { paddingLeft: 2, gap: 1, children: [_jsx(Text, { dimColor: true, children: "desc:" }), _jsx(Text, { color: "green", children: prop.description || '—' })] })), selected && rationale && rationale.trim().length > 0 && (_jsx(Box, { paddingLeft: 2, children: _jsx(Text, { dimColor: true, children: (() => {
172
+ return (_jsxs(Box, { flexDirection: "column", width: width, children: [_jsxs(Box, { gap: 1, children: [_jsxs(Text, { color: selected ? PALETTE.inverse : PALETTE.info, bold: selected, backgroundColor: bg, children: [' ', nameDisplay, ' '] }), _jsx(Text, { dimColor: !selected, children: "type:" }), activeField === 'type' ? (_jsx(Picker, { value: prop.type, active: true })) : (_jsx(Text, { color: selected ? PALETTE.warning : PALETTE.inverse, children: prop.type })), _jsx(Text, { dimColor: !selected, children: "cat:" }), activeField === 'category' ? (_jsx(Picker, { value: prop.category, active: true })) : (_jsx(Text, { color: selected ? PALETTE.info : PALETTE.inverse, children: prop.category })), _jsx(Text, { dimColor: !selected, children: "req:" }), activeField === 'required' ? (_jsx(Toggle, { value: prop.required, active: true })) : (_jsx(Toggle, { value: prop.required, active: false })), prop.type === 'token' && (_jsxs(_Fragment, { children: [_jsx(Text, { dimColor: !selected, children: "kind:" }), activeField === 'tokenKind' ? (_jsx(Picker, { value: prop.tokenKind || DESIGN_TOKEN_TYPES[0], active: true })) : (_jsx(Text, { color: selected ? PALETTE.success : PALETTE.inverse, children: prop.tokenKind || '—' }))] }))] }), selected && (_jsx(DefaultSubRow, { prop: prop, active: activeField === 'default', textCursor: textCursor, cursorVisible: cursorVisible })), selected && descActive && (_jsxs(Box, { paddingLeft: 2, flexDirection: "row", children: [_jsx(Text, { dimColor: true, children: "desc:" }), _jsxs(Box, { flexGrow: 1, borderStyle: "round", borderColor: PALETTE.info, paddingX: 1, children: [_jsx(Text, { children: prop.description.slice(0, textCursor) }), _jsx(Text, { inverse: cursorVisible, children: prop.description[textCursor] ?? cursor }), _jsx(Text, { children: prop.description.slice(textCursor + 1) })] })] })), selected && !descActive && (_jsxs(Box, { paddingLeft: 2, gap: 1, children: [_jsx(Text, { dimColor: true, children: "desc:" }), _jsx(Text, { color: PALETTE.success, children: prop.description || '—' })] })), selected && rationale && rationale.trim().length > 0 && (_jsx(Box, { paddingLeft: 2, children: _jsx(Text, { dimColor: true, children: (() => {
192
173
  const max = Math.max(8, width - 8);
193
174
  const text = `~ ${rationale}`;
194
175
  return text.length > max ? text.slice(0, max - 1) + '…' : text;
@@ -196,32 +177,42 @@ function PropRow({ prop, selected, activeField, textCursor, valueCursor, cursorV
196
177
  const isActiveCursor = activeField === 'values' && valueCursor === i;
197
178
  const isBeingEdited = editingValue?.mode === 'edit' && editingValue.index === i;
198
179
  if (isBeingEdited) {
199
- return (_jsxs(Box, { paddingLeft: 2, children: [_jsx(Text, { color: "cyan", children: '✎ ' }), _jsx(Text, { children: valueText }), _jsx(Text, { inverse: cursorVisible, children: " " })] }, i));
180
+ return (_jsxs(Box, { paddingLeft: 2, children: [_jsx(Text, { color: PALETTE.info, children: '✎ ' }), _jsx(Text, { children: valueText }), _jsx(Text, { inverse: cursorVisible, children: " " })] }, i));
200
181
  }
201
- return (_jsx(Box, { gap: 1, paddingLeft: 2, children: _jsx(Text, { color: isActiveCursor ? 'cyan' : 'white', children: isActiveCursor ? `▶ ${v}` : ` ${v}` }) }, i));
202
- }), editingValue?.mode === 'add' && (_jsxs(Box, { paddingLeft: 2, children: [_jsx(Text, { color: "cyan", children: '+ ' }), _jsx(Text, { children: valueText }), _jsx(Text, { inverse: cursorVisible, children: " " })] }))] }))] }));
182
+ return (_jsx(Box, { gap: 1, paddingLeft: 2, children: _jsx(Text, { color: isActiveCursor ? PALETTE.info : PALETTE.inverse, children: isActiveCursor ? `▶ ${v}` : ` ${v}` }) }, i));
183
+ }), editingValue?.mode === 'add' && (_jsxs(Box, { paddingLeft: 2, children: [_jsx(Text, { color: PALETTE.info, children: '+ ' }), _jsx(Text, { children: valueText }), _jsx(Text, { inverse: cursorVisible, children: " " })] }))] }))] }));
203
184
  }
204
- function SlotRow({ slot, selected, activeField, textCursor, valueCursor, cursorVisible, editingValue, valueText, width, }) {
185
+ function SlotRow({ slot, selected, activeField, textCursor, valueCursor, cursorVisible, editingValue, valueText, width, pickerCandidates, pickerCursor, }) {
205
186
  const cursor = cursorVisible ? '█' : ' ';
206
187
  const bg = selected ? 'blue' : undefined;
207
188
  const nameDisplay = slot.name.length > 14 ? slot.name.slice(0, 13) + '…' : slot.name.padEnd(14);
208
- return (_jsxs(Box, { flexDirection: "column", width: width, children: [_jsxs(Box, { gap: 1, children: [_jsxs(Text, { color: selected ? 'white' : 'cyan', bold: selected, backgroundColor: bg, children: [' ', nameDisplay, ' '] }), _jsx(Text, { dimColor: !selected, children: "req:" }), activeField === 'required' ? (_jsx(Toggle, { value: slot.required, active: true })) : (_jsx(Toggle, { value: slot.required, active: false }))] }), selected && (_jsxs(Box, { paddingLeft: 2, flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: "allowed:" }), activeField === 'allowedComponents' && (_jsx(Text, { dimColor: true, children: ' [a]dd [e]dit [r]emove [↑↓] navigate [K/J] reorder' }))] }), slot.allowedComponents.length === 0 && !editingValue && (_jsx(Box, { paddingLeft: 2, children: _jsx(Text, { dimColor: true, children: activeField === 'allowedComponents' ? '(any press [a] to add)' : '(any)' }) })), slot.allowedComponents.map((v, i) => {
189
+ return (_jsxs(Box, { flexDirection: "column", width: width, children: [_jsxs(Box, { gap: 1, children: [_jsxs(Text, { color: selected ? PALETTE.inverse : PALETTE.info, bold: selected, backgroundColor: bg, children: [' ', nameDisplay, ' '] }), _jsx(Text, { dimColor: !selected, children: "req:" }), activeField === 'required' ? (_jsx(Toggle, { value: slot.required, active: true })) : (_jsx(Toggle, { value: slot.required, active: false }))] }), !selected && (_jsxs(Box, { paddingLeft: 2, gap: 1, children: [_jsx(Text, { dimColor: true, children: "allowed:" }), slot.allowedComponents.length === 0 ? (_jsx(Text, { dimColor: true, children: "(any)" })) : (_jsx(Text, { color: PALETTE.info, children: slot.allowedComponents.join(', ') }))] })), selected && (_jsxs(Box, { paddingLeft: 2, flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: "allowed:" }), activeField === 'allowedComponents' && (_jsx(Text, { dimColor: true, children: slot.allowedComponents.length > 0
190
+ ? ' [a]dd [e]dit [r]emove [←→] cycle [↑↓] navigate [K/J] reorder'
191
+ : ' [a]dd [e]dit [r]emove [↑↓] navigate [K/J] reorder' }))] }), slot.allowedComponents.length === 0 && !editingValue && (_jsx(Box, { paddingLeft: 2, children: _jsx(Text, { dimColor: true, children: activeField === 'allowedComponents' ? '(any — press [a] to add)' : '(any)' }) })), slot.allowedComponents.map((v, i) => {
209
192
  const isActiveCursor = activeField === 'allowedComponents' && valueCursor === i;
210
193
  const isBeingEdited = editingValue?.mode === 'edit' && editingValue.index === i;
211
194
  if (isBeingEdited) {
212
- return (_jsxs(Box, { paddingLeft: 2, children: [_jsx(Text, { color: "cyan", children: '✎ ' }), _jsx(Text, { children: valueText }), _jsx(Text, { inverse: cursorVisible, children: " " })] }, i));
195
+ return (_jsxs(Box, { paddingLeft: 2, children: [_jsx(Text, { color: PALETTE.info, children: '✎ ' }), _jsx(Text, { children: valueText }), _jsx(Text, { inverse: cursorVisible, children: " " })] }, i));
213
196
  }
214
- return (_jsx(Box, { gap: 1, paddingLeft: 2, children: _jsx(Text, { color: isActiveCursor ? 'cyan' : 'white', children: isActiveCursor ? `▶ ${v}` : ` ${v}` }) }, i));
215
- }), editingValue?.mode === 'add' && activeField === 'allowedComponents' && (_jsxs(Box, { paddingLeft: 2, children: [_jsx(Text, { color: "cyan", children: '+ ' }), _jsx(Text, { children: valueText }), _jsx(Text, { inverse: cursorVisible, children: " " })] }))] })), selected && activeField === 'description' && (_jsxs(Box, { paddingLeft: 2, flexDirection: "row", children: [_jsx(Text, { dimColor: true, children: "desc:" }), _jsxs(Box, { flexGrow: 1, borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsx(Text, { children: slot.description.slice(0, textCursor) }), _jsx(Text, { inverse: cursorVisible, children: slot.description[textCursor] ?? cursor }), _jsx(Text, { children: slot.description.slice(textCursor + 1) })] })] })), selected && activeField !== 'description' && (_jsxs(Box, { paddingLeft: 2, gap: 1, children: [_jsx(Text, { dimColor: true, children: "desc:" }), _jsx(Text, { color: "green", children: slot.description || '—' })] }))] }));
197
+ return (_jsx(Box, { gap: 1, paddingLeft: 2, children: _jsx(Text, { color: isActiveCursor ? PALETTE.info : PALETTE.inverse, children: isActiveCursor ? `▶ ${v}` : ` ${v}` }) }, i));
198
+ }), editingValue?.mode === 'add' && activeField === 'allowedComponents' && (_jsxs(Box, { paddingLeft: 2, children: [_jsx(Text, { color: PALETTE.info, children: '+ ' }), _jsx(Text, { children: valueText }), _jsx(Text, { inverse: cursorVisible, children: " " })] })), editingValue?.mode === 'add' && activeField === 'allowedComponents' && pickerCandidates !== null && (_jsx(Box, { paddingLeft: 2, flexDirection: "column", children: pickerCandidates.length === 0 ? (_jsx(Text, { dimColor: true, children: '(no valid components to add all remaining candidates would create cycles)' })) : ((() => {
199
+ const filtered = valueText.length === 0
200
+ ? pickerCandidates
201
+ : pickerCandidates.filter((n) => n.toLowerCase().includes(valueText.toLowerCase()));
202
+ if (filtered.length === 0) {
203
+ return _jsx(Text, { dimColor: true, children: '(no candidates match — Enter to add as free text)' });
204
+ }
205
+ const cursor = pickerCursor % filtered.length;
206
+ const MAX_VISIBLE = 5;
207
+ const start = Math.max(0, Math.min(filtered.length - MAX_VISIBLE, cursor - 2));
208
+ const slice = filtered.slice(start, start + MAX_VISIBLE);
209
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: ' candidates (↑↓ cycle, Enter to add):' }), slice.map((name, i) => {
210
+ const absIdx = start + i;
211
+ const isCursor = absIdx === cursor;
212
+ return (_jsx(Text, { color: isCursor ? PALETTE.info : undefined, dimColor: !isCursor, children: isCursor ? ` ▶ ${name}` : ` ${name}` }, name));
213
+ })] }));
214
+ })()) }))] })), selected && activeField === 'description' && (_jsxs(Box, { paddingLeft: 2, flexDirection: "row", children: [_jsx(Text, { dimColor: true, children: "desc:" }), _jsxs(Box, { flexGrow: 1, borderStyle: "round", borderColor: PALETTE.info, paddingX: 1, children: [_jsx(Text, { children: slot.description.slice(0, textCursor) }), _jsx(Text, { inverse: cursorVisible, children: slot.description[textCursor] ?? cursor }), _jsx(Text, { children: slot.description.slice(textCursor + 1) })] })] })), selected && activeField !== 'description' && (_jsxs(Box, { paddingLeft: 2, gap: 1, children: [_jsx(Text, { dimColor: true, children: "desc:" }), _jsx(Text, { color: PALETTE.success, children: slot.description || '—' })] }))] }));
216
215
  }
217
- // Field order is intentional: description is LAST so it's the "edge" of the
218
- // field cycle. The user reaches description by walking through type → category
219
- // → required → [tokenKind?] → [values?] → [default?] → description via j/k;
220
- // once active, description swallows j/k as literal text input. Putting it last
221
- // means the user has to deliberately navigate to it, avoiding accidental
222
- // text-entry. `default` slots in BEFORE description for the same reason —
223
- // description-as-last is invariant. richtext/media/link omit `default` because
224
- // defaults aren't meaningful for those types (per spec D1).
225
216
  function propFields(prop) {
226
217
  const fields = ['type', 'category', 'required'];
227
218
  if (prop.type === 'token')
@@ -234,26 +225,130 @@ function propFields(prop) {
234
225
  fields.push('description');
235
226
  return fields;
236
227
  }
237
- // Slot fields: description stays last for the same invariant reason. Allowed-
238
- // components is a list-typed field that swallows j/k similarly to enum $values
239
- // — placing it before description preserves description-as-last.
240
228
  const SLOT_FIELDS = ['required', 'allowedComponents', 'description'];
241
- // ── Main component ────────────────────────────────────────────────────────────
242
- export function FieldEditor({ value, width, height, active = true, onChange, onSave, onDiscard, onExit, metadata, onTogglePropRationale, onToggleComponentRationale, onToggleSourceExternal, onTextEntryActiveChange, }) {
229
+ export function simulateGraphWithCandidate(projectSlotGraph, selfName, currentSlots, targetSlotName, candidate) {
230
+ const selfEntry = {
231
+ name: selfName,
232
+ slots: currentSlots.map((s) => ({
233
+ name: s.name,
234
+ allowedComponents: s.name === targetSlotName ? [...s.allowedComponents, candidate] : [...s.allowedComponents],
235
+ })),
236
+ };
237
+ const withoutSelf = projectSlotGraph.filter((c) => c.name !== selfName);
238
+ return [...withoutSelf, selfEntry];
239
+ }
240
+ export function introducesNewCycle(before, next) {
241
+ const key = (edges) => {
242
+ if (edges.length === 0)
243
+ return '';
244
+ const encoded = edges.map((e) => `${e.fromComponent}|${e.slotName}|${e.toComponent}`);
245
+ let minIdx = 0;
246
+ for (let i = 1; i < encoded.length; i += 1)
247
+ if (encoded[i] < encoded[minIdx])
248
+ minIdx = i;
249
+ return [...encoded.slice(minIdx), ...encoded.slice(0, minIdx)].join(';');
250
+ };
251
+ const beforeSet = new Set(before.map((c) => key(c.edges)));
252
+ for (const cycle of next)
253
+ if (!beforeSet.has(key(cycle.edges)))
254
+ return true;
255
+ return false;
256
+ }
257
+ export function computeAllowedComponentCandidates(projectSlotGraph, selfName, currentSlots, targetSlotName) {
258
+ const targetSlot = currentSlots.find((s) => s.name === targetSlotName);
259
+ const alreadyAdded = new Set(targetSlot?.allowedComponents ?? []);
260
+ const universe = new Set();
261
+ for (const c of projectSlotGraph)
262
+ universe.add(c.name);
263
+ universe.delete(selfName);
264
+ for (const added of alreadyAdded)
265
+ universe.delete(added);
266
+ const baseline = findSlotCycles(simulateGraphWithCandidate(projectSlotGraph, selfName, currentSlots, '', ''));
267
+ const safe = [];
268
+ for (const candidate of universe) {
269
+ const nextCycles = findSlotCycles(simulateGraphWithCandidate(projectSlotGraph, selfName, currentSlots, targetSlotName, candidate));
270
+ if (!introducesNewCycle(baseline, nextCycles))
271
+ safe.push(candidate);
272
+ }
273
+ safe.sort((a, b) => a.localeCompare(b));
274
+ return safe;
275
+ }
276
+ export function simulateGraphWithReplacement(projectSlotGraph, selfName, currentSlots, targetSlotName, replaceIndex, candidate) {
277
+ const selfEntry = {
278
+ name: selfName,
279
+ slots: currentSlots.map((s) => {
280
+ if (s.name !== targetSlotName)
281
+ return { name: s.name, allowedComponents: [...s.allowedComponents] };
282
+ const nextVals = s.allowedComponents.map((v, i) => (i === replaceIndex ? candidate : v));
283
+ return { name: s.name, allowedComponents: nextVals };
284
+ }),
285
+ };
286
+ const withoutSelf = projectSlotGraph.filter((c) => c.name !== selfName);
287
+ return [...withoutSelf, selfEntry];
288
+ }
289
+ export function computeAllowedComponentReplacementCandidates(projectSlotGraph, selfName, currentSlots, targetSlotName, replaceIndex) {
290
+ const targetSlot = currentSlots.find((s) => s.name === targetSlotName);
291
+ const existing = targetSlot?.allowedComponents ?? [];
292
+ const excludeOtherEntries = new Set();
293
+ existing.forEach((v, i) => {
294
+ if (i !== replaceIndex)
295
+ excludeOtherEntries.add(v);
296
+ });
297
+ const universe = new Set();
298
+ for (const c of projectSlotGraph)
299
+ universe.add(c.name);
300
+ universe.delete(selfName);
301
+ for (const other of excludeOtherEntries)
302
+ universe.delete(other);
303
+ const baseline = findSlotCycles(simulateGraphWithCandidate(projectSlotGraph, selfName, currentSlots, '', ''));
304
+ const safe = [];
305
+ for (const candidate of universe) {
306
+ const nextCycles = findSlotCycles(simulateGraphWithReplacement(projectSlotGraph, selfName, currentSlots, targetSlotName, replaceIndex, candidate));
307
+ if (!introducesNewCycle(baseline, nextCycles))
308
+ safe.push(candidate);
309
+ }
310
+ safe.sort((a, b) => a.localeCompare(b));
311
+ return safe;
312
+ }
313
+ export function FieldEditor({ value, width, height, active = true, onChange, onSave, onDiscard, onExit, metadata, onTogglePropRationale, propRationaleKey = 'i', onToggleComponentRationale, componentRationaleKey = 'I', onToggleSourceExternal, onTextEntryActiveChange, projectSlotGraph, currentComponentName, onDirtyChange, discardTrigger, initialFocusTarget, }) {
243
314
  const { state: initialState, error: parseError } = parseToState(value);
244
315
  const [editorState, setEditorState] = useState(initialState);
245
316
  const [parseErr] = useState(parseError);
246
- // Navigation state — initial state lands at the row level with NO field
247
- // auto-active. The user presses Return to enter field-edit at the first
248
- // field of the row (type), then j/k to walk through fields uniformly:
249
- // type → category → required → [tokenKind?] → [values?] → description.
250
- // Description is reached via navigation, not auto-focus — this avoids
251
- // trapping the user in description-edit (where j/k type literals).
252
317
  const initialFocus = (() => {
318
+ if (initialFocusTarget) {
319
+ if (initialFocusTarget.kind === 'prop') {
320
+ const idx = initialState.props.findIndex((p) => p.name === initialFocusTarget.name);
321
+ if (idx >= 0) {
322
+ return {
323
+ focusLevel: 'prop',
324
+ inSlots: false,
325
+ propIdx: idx,
326
+ slotIdx: 0,
327
+ activeField: null,
328
+ textCursor: 0,
329
+ };
330
+ }
331
+ }
332
+ else {
333
+ const idx = initialState.slots.findIndex((s) => s.name === initialFocusTarget.name);
334
+ if (idx >= 0) {
335
+ return {
336
+ focusLevel: 'slot',
337
+ inSlots: true,
338
+ propIdx: 0,
339
+ slotIdx: idx,
340
+ activeField: null,
341
+ textCursor: 0,
342
+ };
343
+ }
344
+ }
345
+ }
253
346
  if (initialState.props.length > 0) {
254
347
  return {
255
348
  focusLevel: 'prop',
256
349
  inSlots: false,
350
+ propIdx: 0,
351
+ slotIdx: 0,
257
352
  activeField: null,
258
353
  textCursor: 0,
259
354
  };
@@ -262,6 +357,8 @@ export function FieldEditor({ value, width, height, active = true, onChange, onS
262
357
  return {
263
358
  focusLevel: 'slot',
264
359
  inSlots: true,
360
+ propIdx: 0,
361
+ slotIdx: 0,
265
362
  activeField: null,
266
363
  textCursor: 0,
267
364
  };
@@ -269,46 +366,31 @@ export function FieldEditor({ value, width, height, active = true, onChange, onS
269
366
  return {
270
367
  focusLevel: 'prop',
271
368
  inSlots: false,
369
+ propIdx: 0,
370
+ slotIdx: 0,
272
371
  activeField: null,
273
372
  textCursor: 0,
274
373
  };
275
374
  })();
276
375
  const [focusLevel, setFocusLevel] = useState(initialFocus.focusLevel);
277
- const [propIdx, setPropIdx] = useState(0);
278
- const [slotIdx, setSlotIdx] = useState(0);
279
- // Whether we're navigating props (false) or slots (true) at the top level
376
+ const [propIdx, setPropIdx] = useState(initialFocus.propIdx);
377
+ const [slotIdx, setSlotIdx] = useState(initialFocus.slotIdx);
280
378
  const [inSlots, setInSlots] = useState(initialFocus.inSlots);
281
- // True when the active focus is the component-level $description row (rather
282
- // than a prop/slot row). Lives parallel to inSlots — they're mutually exclusive.
283
379
  const [inComponentDesc, setInComponentDesc] = useState(false);
284
- // Active field within a prop/slot
285
380
  const [activeField, setActiveField] = useState(initialFocus.activeField);
286
- // Text cursor position for description fields
287
381
  const [textCursor, setTextCursor] = useState(initialFocus.textCursor);
288
- // Value list cursor for $values editing
289
382
  const [valueCursor, setValueCursor] = useState(0);
290
- // Inline text-entry mode for adding/editing a $values entry.
291
- // mode='add' — append on Enter; mode='edit' — replace at index on Enter.
292
383
  const [editingValue, setEditingValue] = useState(null);
293
384
  const [valueText, setValueText] = useState('');
385
+ const [pickerCursor, setPickerCursor] = useState(0);
294
386
  const [validationError, setValidationError] = useState(null);
295
387
  const [cursorVisible] = useState(true);
296
- // Feature 1: source-view panel toggle. Opens with `s`, closes with `s` or Esc.
297
- // When open, Esc closes the panel only (does not bubble to onExit).
298
388
  const [sourceOpen, setSourceOpen] = useState(false);
299
- // Feature 11: rationale panel toggle (`i`). Mutually exclusive with the
300
- // source panel — opening one closes the other.
301
389
  const [rationaleOpen, setRationaleOpen] = useState(false);
302
390
  const [rationaleScrollOffset, setRationaleScrollOffset] = useState(0);
303
- // Discoverability overlay. `?` toggles a modal listing every wired
304
- // keybinding grouped by context. While open, every other handler is inert
305
- // — only `?` and Esc respond. Esc here closes the overlay and does NOT
306
- // bubble to onExit.
307
391
  const [showHelp, setShowHelp] = useState(false);
308
392
  const props = editorState.props;
309
393
  const slots = editorState.slots;
310
- // Report text-entry-active state to parent so it can gate top-level `i`/`I`/`s`
311
- // keybinds against literal keystrokes inside any text-entry surface.
312
394
  const textEntryActive = (focusLevel === 'field' && activeField === 'description') ||
313
395
  (focusLevel === 'field' &&
314
396
  activeField === 'default' &&
@@ -319,26 +401,44 @@ export function FieldEditor({ value, width, height, active = true, onChange, onS
319
401
  }, [textEntryActive, onTextEntryActiveChange]);
320
402
  const currentProp = props[propIdx] ?? null;
321
403
  const currentSlot = slots[slotIdx] ?? null;
322
- // Emit serialized JSON whenever state changes
323
404
  const commit = (next) => {
324
405
  setEditorState(next);
325
406
  onChange(serializeState(next, value));
326
407
  };
408
+ const canonicalize = React.useCallback((json) => {
409
+ try {
410
+ return JSON.stringify(JSON.parse(json));
411
+ }
412
+ catch {
413
+ return `__unparseable__:${json}`;
414
+ }
415
+ }, []);
416
+ const initialStateRef = React.useRef(initialState);
417
+ const [baselineCanonical, setBaselineCanonical] = useState(() => canonicalize(serializeState(initialState, value)));
418
+ const currentCanonical = canonicalize(serializeState(editorState, value));
419
+ const isDirty = currentCanonical !== baselineCanonical;
420
+ React.useEffect(() => {
421
+ onDirtyChange?.(isDirty);
422
+ }, [isDirty, onDirtyChange]);
423
+ const lastDiscardTriggerRef = React.useRef(discardTrigger);
424
+ React.useEffect(() => {
425
+ if (discardTrigger === undefined)
426
+ return;
427
+ if (discardTrigger === lastDiscardTriggerRef.current)
428
+ return;
429
+ lastDiscardTriggerRef.current = discardTrigger;
430
+ setEditorState(initialStateRef.current);
431
+ onChange(serializeState(initialStateRef.current, value));
432
+ }, [discardTrigger, onChange, value]);
327
433
  useImmediateInput((input, key) => {
328
434
  if (!active)
329
435
  return;
330
- // ── Help overlay (`?`) — highest priority ───────────────────────────────
331
- // When open, only `?` (toggle) and Esc (close) respond. All other input
332
- // is swallowed so j/k/Enter/Ctrl+S can't move state behind the modal.
333
436
  if (showHelp) {
334
437
  if (input === '?' || key.escape) {
335
438
  setShowHelp(false);
336
439
  }
337
440
  return;
338
441
  }
339
- // Open the overlay. Skip when in any inline text-entry context to keep
340
- // `?` literal there: description text-entry, string-typed default
341
- // text-entry, and the values/allowedComponents add/edit text-entry.
342
442
  const inDescriptionTextEntryForHelp = focusLevel === 'field' && activeField === 'description';
343
443
  const inStringDefaultTextEntryForHelp = focusLevel === 'field' &&
344
444
  activeField === 'default' &&
@@ -354,16 +454,57 @@ export function FieldEditor({ value, width, height, active = true, onChange, onS
354
454
  setShowHelp(true);
355
455
  return;
356
456
  }
357
- // ── Inline value text-entry (add or edit) ─ highest priority ───────────
358
- // Handles enum prop $values AND slot $allowedComponents — both use the
359
- // same add/edit/remove/reorder list shape.
360
457
  const isPropValuesEntry = editingValue && currentProp && activeField === 'values' && !inSlots;
361
458
  const isSlotAllowedEntry = editingValue && currentSlot && activeField === 'allowedComponents' && inSlots;
459
+ const slotAddCandidates = isSlotAllowedEntry && editingValue?.mode === 'add' && projectSlotGraph && currentSlot && currentComponentName
460
+ ? computeAllowedComponentCandidates(projectSlotGraph, currentComponentName, slots, currentSlot.name)
461
+ : null;
462
+ const filteredCandidates = slotAddCandidates
463
+ ? valueText.length === 0
464
+ ? slotAddCandidates
465
+ : slotAddCandidates.filter((n) => n.toLowerCase().includes(valueText.toLowerCase()))
466
+ : null;
362
467
  if (isPropValuesEntry || isSlotAllowedEntry) {
363
468
  const vals = isPropValuesEntry ? currentProp.values : currentSlot.allowedComponents;
469
+ const pickerActive = isSlotAllowedEntry && editingValue?.mode === 'add' && slotAddCandidates !== null;
470
+ if (pickerActive && (key.upArrow || key.downArrow)) {
471
+ const len = filteredCandidates.length;
472
+ if (len > 0) {
473
+ setPickerCursor((c) => (key.upArrow ? (c - 1 + len) % len : (c + 1) % len));
474
+ }
475
+ return;
476
+ }
364
477
  if (key.return) {
365
- const trimmed = valueText.trim();
478
+ let chosen = null;
479
+ if (pickerActive && filteredCandidates && filteredCandidates.length > 0) {
480
+ if (valueText.trim() === '') {
481
+ chosen = filteredCandidates[pickerCursor % filteredCandidates.length] ?? null;
482
+ }
483
+ else {
484
+ const exact = filteredCandidates.find((n) => n === valueText.trim());
485
+ if (exact)
486
+ chosen = exact;
487
+ }
488
+ }
489
+ const trimmed = chosen ?? valueText.trim();
366
490
  if (trimmed) {
491
+ if (isSlotAllowedEntry &&
492
+ editingValue?.mode === 'add' &&
493
+ projectSlotGraph &&
494
+ currentSlot &&
495
+ currentComponentName &&
496
+ chosen === null) {
497
+ if (trimmed === currentComponentName) {
498
+ setValidationError(`"${trimmed}" cannot be added to its own slot (self-loop).`);
499
+ return;
500
+ }
501
+ const baseline = findSlotCycles(simulateGraphWithCandidate(projectSlotGraph, currentComponentName, slots, '', ''));
502
+ const next = findSlotCycles(simulateGraphWithCandidate(projectSlotGraph, currentComponentName, slots, currentSlot.name, trimmed));
503
+ if (introducesNewCycle(baseline, next)) {
504
+ setValidationError(`Adding "${trimmed}" to slot "${currentSlot.name}" would create a slot-dependency cycle. Choose a different component.`);
505
+ return;
506
+ }
507
+ }
367
508
  let nextVals;
368
509
  let cursorAfter;
369
510
  if (editingValue.mode === 'add') {
@@ -384,57 +525,52 @@ export function FieldEditor({ value, width, height, active = true, onChange, onS
384
525
  commit({ ...editorState, slots: nextSlots });
385
526
  }
386
527
  setValueCursor(cursorAfter);
528
+ setValidationError(null);
387
529
  }
388
530
  setEditingValue(null);
389
531
  setValueText('');
532
+ setPickerCursor(0);
390
533
  return;
391
534
  }
392
535
  if (key.escape) {
393
536
  setEditingValue(null);
394
537
  setValueText('');
538
+ setPickerCursor(0);
539
+ setValidationError(null);
395
540
  return;
396
541
  }
397
542
  if (key.backspace) {
398
543
  setValueText((t) => t.slice(0, -1));
544
+ setPickerCursor(0);
399
545
  return;
400
546
  }
401
547
  if (input && input.length === 1 && !key.ctrl && !key.meta) {
402
548
  setValueText((t) => t + input);
549
+ setPickerCursor(0);
403
550
  return;
404
551
  }
405
552
  return;
406
553
  }
407
- // ── Save / Discard ───────────────────────────────────────────────────────
408
554
  if (key.ctrl && input === 's') {
409
555
  setValidationError(null);
556
+ const canonicalNow = canonicalize(serializeState(editorState, value));
557
+ setBaselineCanonical(canonicalNow);
558
+ initialStateRef.current = editorState;
410
559
  onSave();
411
560
  return;
412
561
  }
413
- // ── Feature 1: source-view panel toggle ─────────────────────────────────
414
- // `s` (without Ctrl) opens/closes the source-view panel. Skip when in
415
- // inline text-entry contexts (description text-entry, value-list edit).
416
- // Description text-entry is gated by focusLevel === 'field' && activeField
417
- // === 'description' — we guard against typing 's' as a literal there.
418
562
  const inDescriptionTextEntry = focusLevel === 'field' && activeField === 'description';
419
563
  const inComponentDescTextEntry = focusLevel === 'field' && inComponentDesc && activeField === 'description';
420
564
  if (input === 's' && !key.ctrl && !key.meta && !inDescriptionTextEntry && !inComponentDescTextEntry) {
421
- // When the parent owns the source panel, delegate; otherwise fall
422
- // back to the internal toggle for backward compat.
423
565
  if (onToggleSourceExternal) {
424
566
  onToggleSourceExternal();
425
567
  return;
426
568
  }
427
- // Mutual exclusion with the rationale panel (Feature 11) — only when
428
- // internally managed.
429
569
  setRationaleOpen(false);
430
570
  setRationaleScrollOffset(() => 0);
431
571
  setSourceOpen((o) => !o);
432
572
  return;
433
573
  }
434
- // ── Rationale-panel scroll while internally open (legacy path) ─────────
435
- // When the parent does not own the panel (no onTogglePropRationale prop),
436
- // FieldEditor preserves its legacy in-place panel for backward compat
437
- // with mounts that pre-date the lifted-panel refactor.
438
574
  if (rationaleOpen && !onTogglePropRationale) {
439
575
  const PANEL_HEIGHT = 12;
440
576
  const next = computeNextScrollOffset(rationaleScrollOffset, input, key, 9999, PANEL_HEIGHT);
@@ -449,10 +585,6 @@ export function FieldEditor({ value, width, height, active = true, onChange, onS
449
585
  }
450
586
  return;
451
587
  }
452
- // ── Rationale + component-rationale keybinds (`i` / `I`) ────────────────
453
- // `i`/`I` are literal in every text-entry context: description editor,
454
- // component-description editor, string/token default editor, and the
455
- // values/allowedComponents add/edit text-entry.
456
588
  const inStringDefaultTextEntry = focusLevel === 'field' &&
457
589
  activeField === 'default' &&
458
590
  currentProp != null &&
@@ -463,29 +595,26 @@ export function FieldEditor({ value, width, height, active = true, onChange, onS
463
595
  !inComponentDescTextEntry &&
464
596
  !inStringDefaultTextEntry &&
465
597
  !inValueListTextEntry;
466
- if (input === 'i' && rationaleKeyAllowed) {
467
- if (onTogglePropRationale) {
468
- onTogglePropRationale();
469
- return;
470
- }
471
- // Legacy in-place toggle (no parent callback wired).
598
+ if (input === propRationaleKey && rationaleKeyAllowed && onTogglePropRationale) {
599
+ onTogglePropRationale();
600
+ return;
601
+ }
602
+ if (input === 'i' && rationaleKeyAllowed && !onTogglePropRationale) {
472
603
  setSourceOpen(false);
473
604
  setRationaleScrollOffset(() => 0);
474
605
  setRationaleOpen((o) => !o);
475
606
  return;
476
607
  }
477
- if (input === 'I' && rationaleKeyAllowed && onToggleComponentRationale) {
608
+ if (input === componentRationaleKey && rationaleKeyAllowed && onToggleComponentRationale) {
478
609
  onToggleComponentRationale();
479
610
  return;
480
611
  }
481
- // ── Esc when source panel is open: close panel only, do not bubble ─────
482
612
  if (key.escape && sourceOpen) {
483
613
  setSourceOpen(false);
484
614
  return;
485
615
  }
486
616
  if (key.escape) {
487
617
  if (focusLevel === 'field') {
488
- // Exit field editing back to prop/slot/component-description row level
489
618
  if (inComponentDesc)
490
619
  setFocusLevel('componentDescription');
491
620
  else
@@ -493,8 +622,6 @@ export function FieldEditor({ value, width, height, active = true, onChange, onS
493
622
  setActiveField(null);
494
623
  return;
495
624
  }
496
- // Row-level Esc: bounce focus back out of the panel via onExit.
497
- // Falls back to onDiscard when the caller hasn't wired onExit.
498
625
  if (onExit) {
499
626
  onExit();
500
627
  }
@@ -503,16 +630,12 @@ export function FieldEditor({ value, width, height, active = true, onChange, onS
503
630
  }
504
631
  return;
505
632
  }
506
- // ── Prop-level navigation (not inside a field) ───────────────────────────
507
- // Arrows / j / k move between rows. Return enters field-edit at the FIRST
508
- // field of the current prop (type). No auto-focus on description.
509
633
  if (focusLevel === 'prop') {
510
634
  if (key.upArrow || input === 'k') {
511
635
  if (propIdx > 0) {
512
636
  setPropIdx(propIdx - 1);
513
637
  }
514
638
  else {
515
- // From prop[0], k enters the component-description row above.
516
639
  setFocusLevel('componentDescription');
517
640
  setInSlots(false);
518
641
  setInComponentDesc(true);
@@ -531,7 +654,6 @@ export function FieldEditor({ value, width, height, active = true, onChange, onS
531
654
  return;
532
655
  }
533
656
  if (key.return && currentProp) {
534
- // Enter field editing on the first field of this prop (type).
535
657
  setFocusLevel('field');
536
658
  setActiveField(propFields(currentProp)[0] ?? null);
537
659
  setTextCursor(currentProp.description.length);
@@ -539,14 +661,11 @@ export function FieldEditor({ value, width, height, active = true, onChange, onS
539
661
  }
540
662
  return;
541
663
  }
542
- // ── Component-description row navigation ─────────────────────────────────
543
664
  if (focusLevel === 'componentDescription') {
544
665
  if (key.upArrow || input === 'k') {
545
- // Already at the top-most row — stay put.
546
666
  return;
547
667
  }
548
668
  if (key.downArrow || input === 'j') {
549
- // Move down into the first prop row, or first slot row when no props.
550
669
  if (props.length > 0) {
551
670
  setFocusLevel('prop');
552
671
  setPropIdx(0);
@@ -562,7 +681,6 @@ export function FieldEditor({ value, width, height, active = true, onChange, onS
562
681
  return;
563
682
  }
564
683
  if (key.return) {
565
- // Enter the single field — description text input.
566
684
  setFocusLevel('field');
567
685
  setActiveField('description');
568
686
  setTextCursor(editorState.componentDescription.length);
@@ -570,9 +688,6 @@ export function FieldEditor({ value, width, height, active = true, onChange, onS
570
688
  }
571
689
  return;
572
690
  }
573
- // ── Slot-level navigation ────────────────────────────────────────────────
574
- // Arrows / j / k move between rows. Return enters field-edit at the FIRST
575
- // field of the slot (required). No auto-focus on description.
576
691
  if (focusLevel === 'slot') {
577
692
  if (key.upArrow || input === 'k') {
578
693
  if (slotIdx > 0) {
@@ -599,23 +714,16 @@ export function FieldEditor({ value, width, height, active = true, onChange, onS
599
714
  }
600
715
  return;
601
716
  }
602
- // ── Field-level navigation (inside a prop/slot, selecting which field) ───
603
717
  if (focusLevel === 'field') {
604
718
  const fields = inSlots ? SLOT_FIELDS : currentProp ? propFields(currentProp) : [];
605
719
  const currentFieldIdx = fields.indexOf(activeField);
606
720
  const isDescriptionTextEntry = activeField === 'description';
607
- // String/token defaults are also text-entry; j/k must type literals
608
- // there too (consistency with description). boolean/enum defaults use
609
- // ←/→ pickers so j/k can keep cycling fields.
610
721
  const isDefaultTextEntry = activeField === 'default' &&
611
722
  currentProp != null &&
612
723
  (currentProp.type === 'string' || currentProp.type === 'token');
613
724
  const isValuesNav = activeField === 'values' || activeField === 'allowedComponents';
614
725
  const arrowUp = key.upArrow;
615
726
  const arrowDown = key.downArrow;
616
- // ── Inside values: arrow keys AND j/k navigate BETWEEN VALUES, not
617
- // between fields. Reorder is K/J (capital). Same logic for prop
618
- // enum $values and slot $allowedComponents.
619
727
  if (isValuesNav) {
620
728
  const vals = activeField === 'values' && currentProp
621
729
  ? currentProp.values
@@ -623,23 +731,24 @@ export function FieldEditor({ value, width, height, active = true, onChange, onS
623
731
  ? currentSlot.allowedComponents
624
732
  : null;
625
733
  if (vals !== null) {
734
+ // Move the value cursor within the list. At a boundary, fall through to
735
+ // field navigation so the field itself is escapable (e.g. reach the
736
+ // slot `description` field past `allowedComponents`).
626
737
  if (arrowUp || input === 'k') {
627
- setValueCursor((c) => Math.max(0, c - 1));
628
- return;
738
+ if (valueCursor > 0) {
739
+ setValueCursor((c) => Math.max(0, c - 1));
740
+ return;
741
+ }
629
742
  }
630
- if (arrowDown || input === 'j') {
631
- setValueCursor((c) => Math.max(0, Math.min(vals.length - 1, c + 1)));
632
- return;
743
+ else if (arrowDown || input === 'j') {
744
+ if (valueCursor < vals.length - 1) {
745
+ setValueCursor((c) => Math.min(vals.length - 1, c + 1));
746
+ return;
747
+ }
633
748
  }
634
749
  }
635
750
  }
636
- // ── Field cycling within the current prop/slot.
637
- // Arrows always cycle (including from description, which means in
638
- // description-active state arrows leave text-entry to navigate fields
639
- // of the SAME prop). j/k only cycle when NOT in description, so that
640
- // description preserves literal text-entry for those characters. Use
641
- // Esc to leave the current prop and return to row-level navigation.
642
- if (!isValuesNav) {
751
+ {
643
752
  const navUp = arrowUp || (!isDescriptionTextEntry && !isDefaultTextEntry && input === 'k');
644
753
  const navDown = arrowDown || (!isDescriptionTextEntry && !isDefaultTextEntry && input === 'j');
645
754
  if ((navUp || navDown) && fields.length > 0) {
@@ -658,14 +767,22 @@ export function FieldEditor({ value, width, height, active = true, onChange, onS
658
767
  setTextCursor(desc.length);
659
768
  }
660
769
  else if (next === 'default' && currentProp) {
661
- // Land cursor at end of any string-typed default for ergonomic typing.
662
770
  const cur = typeof currentProp.default === 'string' ? currentProp.default : '';
663
771
  setTextCursor(cur.length);
664
772
  }
773
+ else if (next === 'values' || next === 'allowedComponents') {
774
+ const nextVals = next === 'values' && currentProp
775
+ ? currentProp.values
776
+ : next === 'allowedComponents' && currentSlot
777
+ ? currentSlot.allowedComponents
778
+ : [];
779
+ // Land at the entry edge matching travel direction so continued
780
+ // nav moves through the list before escaping the field again.
781
+ setValueCursor(navDown ? 0 : Math.max(0, nextVals.length - 1));
782
+ }
665
783
  return;
666
784
  }
667
785
  }
668
- // ── Picker fields (left/right cycle) ──────────────────────────────────
669
786
  if (activeField === 'type' && (key.leftArrow || key.rightArrow) && currentProp) {
670
787
  const options = CDF_PROPERTY_TYPES;
671
788
  const idx = options.indexOf(currentProp.type);
@@ -673,7 +790,6 @@ export function FieldEditor({ value, width, height, active = true, onChange, onS
673
790
  ? options[(idx - 1 + options.length) % options.length]
674
791
  : options[(idx + 1) % options.length];
675
792
  const updated = { ...currentProp, type: next };
676
- // Clear token/enum specific fields when switching away
677
793
  if (next !== 'token')
678
794
  updated.tokenKind = '';
679
795
  if (next !== 'enum')
@@ -703,7 +819,6 @@ export function FieldEditor({ value, width, height, active = true, onChange, onS
703
819
  commit({ ...editorState, props: nextProps });
704
820
  return;
705
821
  }
706
- // ── Toggle fields (space/enter) ────────────────────────────────────────
707
822
  if (activeField === 'required' && (key.return || input === ' ')) {
708
823
  if (inSlots && currentSlot) {
709
824
  const nextSlots = slots.map((s, i) => (i === slotIdx ? { ...s, required: !s.required } : s));
@@ -715,13 +830,11 @@ export function FieldEditor({ value, width, height, active = true, onChange, onS
715
830
  }
716
831
  return;
717
832
  }
718
- // ── $default editing per type ─────────────────────────────────────────
719
833
  if (activeField === 'default' && currentProp) {
720
834
  const setProp = (next) => {
721
835
  const nextProps = props.map((p, i) => (i === propIdx ? next : p));
722
836
  commit({ ...editorState, props: nextProps });
723
837
  };
724
- // boolean: tri-state cycle (unset → true → false → unset).
725
838
  if (currentProp.type === 'boolean' && (key.leftArrow || key.rightArrow)) {
726
839
  const cycle = [null, true, false];
727
840
  const curIdx = cycle.findIndex((v) => v === currentProp.default);
@@ -730,7 +843,6 @@ export function FieldEditor({ value, width, height, active = true, onChange, onS
730
843
  setProp({ ...currentProp, default: cycle[nextIdx] });
731
844
  return;
732
845
  }
733
- // enum: cycle through prop.values plus (unset).
734
846
  if (currentProp.type === 'enum' && (key.leftArrow || key.rightArrow)) {
735
847
  const opts = [null, ...currentProp.values];
736
848
  const cur = typeof currentProp.default === 'string' ? currentProp.default : null;
@@ -740,7 +852,6 @@ export function FieldEditor({ value, width, height, active = true, onChange, onS
740
852
  setProp({ ...currentProp, default: opts[nextIdx] });
741
853
  return;
742
854
  }
743
- // string/token: text input. Mirrors description input subroutine.
744
855
  if (currentProp.type === 'string' || currentProp.type === 'token') {
745
856
  const cur = typeof currentProp.default === 'string' ? currentProp.default : '';
746
857
  const setVal = (next) => setProp({ ...currentProp, default: next === '' ? null : next });
@@ -773,7 +884,6 @@ export function FieldEditor({ value, width, height, active = true, onChange, onS
773
884
  }
774
885
  return;
775
886
  }
776
- // ── Description text input ─────────────────────────────────────────────
777
887
  if (activeField === 'description') {
778
888
  const getDesc = () => inComponentDesc
779
889
  ? editorState.componentDescription
@@ -829,15 +939,36 @@ export function FieldEditor({ value, width, height, active = true, onChange, onS
829
939
  }
830
940
  return;
831
941
  }
832
- // ── $allowedComponents inline manipulation (slot-level) ──────────────
833
- // Mirrors the $values block exactly, but operates on the slot's
834
- // allowedComponents list.
835
942
  if (activeField === 'allowedComponents' && currentSlot) {
836
943
  const vals = currentSlot.allowedComponents;
837
944
  const setSlotVals = (next) => {
838
945
  const nextSlots = slots.map((s, i) => (i === slotIdx ? { ...s, allowedComponents: next } : s));
839
946
  commit({ ...editorState, slots: nextSlots });
840
947
  };
948
+ if ((key.leftArrow || key.rightArrow || input === 'h' || input === 'l') &&
949
+ vals.length > 0 &&
950
+ projectSlotGraph &&
951
+ currentComponentName) {
952
+ const candidates = computeAllowedComponentReplacementCandidates(projectSlotGraph, currentComponentName, slots, currentSlot.name, valueCursor);
953
+ if (candidates.length === 0) {
954
+ setValidationError('no other valid components for this position');
955
+ return;
956
+ }
957
+ const current = vals[valueCursor] ?? '';
958
+ const curIdx = candidates.indexOf(current);
959
+ const forward = key.rightArrow || input === 'l';
960
+ const baseIdx = curIdx < 0 ? (forward ? -1 : 0) : curIdx;
961
+ const nextIdx = forward
962
+ ? (baseIdx + 1) % candidates.length
963
+ : (baseIdx - 1 + candidates.length) % candidates.length;
964
+ const next = candidates[nextIdx];
965
+ if (next === undefined || next === current)
966
+ return;
967
+ const nextVals = vals.map((v, i) => (i === valueCursor ? next : v));
968
+ setSlotVals(nextVals);
969
+ setValidationError(null);
970
+ return;
971
+ }
841
972
  if (input === 'a') {
842
973
  setEditingValue({ mode: 'add' });
843
974
  setValueText('');
@@ -870,22 +1001,18 @@ export function FieldEditor({ value, width, height, active = true, onChange, onS
870
1001
  }
871
1002
  return;
872
1003
  }
873
- // ── $values inline manipulation (flat — no extra Return) ───────────────
874
1004
  if (activeField === 'values' && currentProp) {
875
1005
  const vals = currentProp.values;
876
- // a — add new value (inline text-entry)
877
1006
  if (input === 'a') {
878
1007
  setEditingValue({ mode: 'add' });
879
1008
  setValueText('');
880
1009
  return;
881
1010
  }
882
- // e — edit value at cursor (inline text-entry, pre-filled)
883
1011
  if (input === 'e' && vals.length > 0) {
884
1012
  setEditingValue({ mode: 'edit', index: valueCursor });
885
1013
  setValueText(vals[valueCursor] ?? '');
886
1014
  return;
887
1015
  }
888
- // r — remove value at cursor
889
1016
  if (input === 'r' && vals.length > 0) {
890
1017
  const nextVals = vals.filter((_, i) => i !== valueCursor);
891
1018
  const nextProps = props.map((p, i) => (i === propIdx ? { ...p, values: nextVals } : p));
@@ -893,7 +1020,6 @@ export function FieldEditor({ value, width, height, active = true, onChange, onS
893
1020
  setValueCursor((c) => Math.max(0, Math.min(c, nextVals.length - 1)));
894
1021
  return;
895
1022
  }
896
- // K (Shift+K) — move value up
897
1023
  if (input === 'K' && valueCursor > 0) {
898
1024
  const nextVals = [...vals];
899
1025
  [nextVals[valueCursor - 1], nextVals[valueCursor]] = [nextVals[valueCursor], nextVals[valueCursor - 1]];
@@ -902,7 +1028,6 @@ export function FieldEditor({ value, width, height, active = true, onChange, onS
902
1028
  setValueCursor((c) => c - 1);
903
1029
  return;
904
1030
  }
905
- // J (Shift+J) — move value down
906
1031
  if (input === 'J' && valueCursor < vals.length - 1) {
907
1032
  const nextVals = [...vals];
908
1033
  [nextVals[valueCursor], nextVals[valueCursor + 1]] = [nextVals[valueCursor + 1], nextVals[valueCursor]];
@@ -916,18 +1041,14 @@ export function FieldEditor({ value, width, height, active = true, onChange, onS
916
1041
  return;
917
1042
  }
918
1043
  });
919
- // ── Render ────────────────────────────────────────────────────────────────
920
1044
  const innerWidth = Math.max(1, width - 2);
921
1045
  if (parseErr) {
922
- return (_jsxs(Box, { flexDirection: "column", width: width, borderStyle: "single", borderColor: "red", children: [_jsx(Text, { bold: true, color: "red", children: "FIELD EDITOR \u2014 parse error" }), _jsx(Text, { color: "red", children: parseErr }), _jsx(Text, { dimColor: true, children: "Cannot display structured editor. Fix the JSON first." })] }));
1046
+ return (_jsxs(Box, { flexDirection: "column", width: width, borderStyle: "single", borderColor: PALETTE.error, children: [_jsx(Text, { bold: true, color: PALETTE.error, children: "FIELD EDITOR \u2014 parse error" }), _jsx(Text, { color: PALETTE.error, children: parseErr }), _jsx(Text, { dimColor: true, children: "Cannot display structured editor. Fix the JSON first." })] }));
923
1047
  }
924
1048
  if (props.length === 0 && slots.length === 0) {
925
- return (_jsxs(Box, { flexDirection: "column", width: width, borderStyle: "single", borderColor: "yellow", children: [_jsx(Text, { bold: true, color: "yellow", children: "FIELD EDITOR \u2014 no fields" }), _jsx(Text, { color: "yellow", children: "⚠ No properties classified for this component. The LLM didn't find anything to classify." }), _jsx(Text, { dimColor: true, children: "You can add fields manually below or reject this component." }), _jsx(Text, { dimColor: true, children: "Ctrl+S to save \u00B7 Esc to discard" })] }));
1049
+ return (_jsxs(Box, { flexDirection: "column", width: width, borderStyle: "single", borderColor: PALETTE.warning, children: [_jsx(Text, { bold: true, color: PALETTE.warning, children: "FIELD EDITOR \u2014 no fields" }), _jsx(Text, { color: PALETTE.warning, children: "⚠ No properties classified for this component. The LLM didn't find anything to classify." }), _jsx(Text, { dimColor: true, children: "You can add fields manually below or reject this component." }), _jsx(Text, { dimColor: true, children: "Ctrl+S to save \u00B7 Esc to discard" })] }));
926
1050
  }
927
- // When $properties is empty but $slots exist, surface the same warning
928
- // prominently in the panel so the user understands why the component looks
929
- // sparse — and can act on it (manually add a prop or reject).
930
- const hasEmptyProperties = props.length === 0;
1051
+ const hasEmptyProperties = props.length === 0 && slots.length === 0;
931
1052
  const modeLabel = (() => {
932
1053
  if (editingValue) {
933
1054
  return editingValue.mode === 'add' ? 'Enter to add · Esc to cancel' : 'Enter to save edit · Esc to cancel';
@@ -948,36 +1069,33 @@ export function FieldEditor({ value, width, height, active = true, onChange, onS
948
1069
  if (rationaleOpen) {
949
1070
  return 'jk/Ctrl+u/d scroll i/Esc close rationale panel';
950
1071
  }
951
- return '↑↓/jk navigate rows Enter edit fields s source i prop rationale I component rationale ? help Ctrl+S save Esc exit panel';
1072
+ return `↑↓/jk navigate rows Enter edit fields s source ${propRationaleKey} prop rationale ${componentRationaleKey} component rationale ? help Ctrl+S save Esc exit panel`;
952
1073
  })();
953
1074
  const rows = [];
954
- // Component-level $description always renders first so it's reachable as
955
- // the topmost navigable row, even when empty (the operator can populate it).
956
1075
  rows.push({ kind: 'component-description' });
957
1076
  if (props.length > 0) {
958
- rows.push({ kind: 'header', label: `── $properties (${props.length}) ` });
1077
+ rows.push({ kind: 'header', label: `── PROPERTIES (${props.length}) `, section: 'properties' });
959
1078
  props.forEach((_, i) => rows.push({ kind: 'prop', idx: i }));
960
1079
  }
961
1080
  if (slots.length > 0) {
962
- rows.push({ kind: 'header', label: `── $slots (${slots.length}) ` });
1081
+ rows.push({ kind: 'header', label: `── SLOTS (${slots.length}) `, section: 'slots' });
963
1082
  slots.forEach((_, i) => rows.push({ kind: 'slot', idx: i }));
964
1083
  }
965
- // Scroll to keep selected row visible
966
1084
  const selectedRowIdx = rows.findIndex((r) => (r.kind === 'prop' && !inSlots && !inComponentDesc && r.idx === propIdx) ||
967
1085
  (r.kind === 'slot' && inSlots && r.idx === slotIdx) ||
968
1086
  (r.kind === 'component-description' && inComponentDesc));
969
- const visibleRows = Math.max(1, height - 3); // title + hint bar + border
1087
+ const visibleRows = Math.max(1, height - 3);
970
1088
  const scrollStart = selectedRowIdx < 0 ? 0 : Math.max(0, Math.min(selectedRowIdx, rows.length - visibleRows));
971
1089
  const visibleRowSlice = rows.slice(scrollStart, scrollStart + visibleRows);
972
- return (_jsxs(Box, { flexDirection: "column", width: width, borderStyle: "single", borderColor: hasEmptyProperties ? 'yellow' : 'cyan', children: [_jsx(Text, { bold: true, color: hasEmptyProperties ? 'yellow' : 'cyan', children: 'FIELDS [Ctrl+S save · Esc discard]' }), hasEmptyProperties && (_jsx(Text, { color: "yellow", children: "⚠ No properties classified for this component. The LLM didn't find anything to classify. Reject this component or add fields manually." })), _jsx(Box, { flexDirection: "column", width: innerWidth, children: visibleRowSlice.map((row, i) => {
1090
+ return (_jsxs(Box, { flexDirection: "column", width: width, borderStyle: "single", borderColor: hasEmptyProperties ? PALETTE.warning : PALETTE.info, children: [_jsx(Text, { bold: true, color: hasEmptyProperties ? PALETTE.warning : PALETTE.info, children: 'FIELDS [Ctrl+S save · Esc discard]' }), hasEmptyProperties && (_jsx(Text, { color: PALETTE.warning, children: "⚠ No properties classified for this component. The LLM didn't find anything to classify. Reject this component or add fields manually." })), _jsx(Box, { flexDirection: "column", width: innerWidth, children: visibleRowSlice.map((row, i) => {
973
1091
  if (row.kind === 'header') {
974
- return (_jsx(Text, { dimColor: true, children: row.label }, `header-${i}`));
1092
+ return (_jsx(Text, { bold: true, color: row.section === 'slots' ? PALETTE.info : PALETTE.success, children: row.label }, `header-${i}`));
975
1093
  }
976
1094
  if (row.kind === 'component-description') {
977
1095
  const isSelected = inComponentDesc;
978
1096
  const isEditing = isSelected && focusLevel === 'field' && activeField === 'description';
979
1097
  const desc = editorState.componentDescription;
980
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { gap: 1, children: _jsx(Text, { color: isSelected ? 'white' : 'cyan', bold: isSelected, backgroundColor: isSelected ? 'blue' : undefined, children: ' component-$description ' }) }), isEditing ? (_jsx(Box, { paddingLeft: 2, flexDirection: "row", children: _jsxs(Box, { flexGrow: 1, borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsx(Text, { children: desc.slice(0, textCursor) }), _jsx(Text, { inverse: cursorVisible, children: desc[textCursor] ?? (cursorVisible ? '█' : ' ') }), _jsx(Text, { children: desc.slice(textCursor + 1) })] }) })) : (_jsx(Box, { paddingLeft: 2, children: _jsx(Text, { color: desc ? 'green' : undefined, dimColor: !desc, children: desc || '(none — Return to edit)' }) }))] }, `component-description-${i}`));
1098
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { gap: 1, children: _jsx(Text, { color: isSelected ? PALETTE.inverse : PALETTE.info, bold: isSelected, backgroundColor: isSelected ? 'blue' : undefined, children: ' component-$description ' }) }), isEditing ? (_jsx(Box, { paddingLeft: 2, flexDirection: "row", children: _jsxs(Box, { flexGrow: 1, borderStyle: "round", borderColor: PALETTE.info, paddingX: 1, children: [_jsx(Text, { children: desc.slice(0, textCursor) }), _jsx(Text, { inverse: cursorVisible, children: desc[textCursor] ?? (cursorVisible ? '█' : ' ') }), _jsx(Text, { children: desc.slice(textCursor + 1) })] }) })) : (_jsx(Box, { paddingLeft: 2, children: _jsx(Text, { color: desc ? PALETTE.success : undefined, dimColor: !desc, children: desc || '(none — Return to edit)' }) }))] }, `component-description-${i}`));
981
1099
  }
982
1100
  if (row.kind === 'prop') {
983
1101
  const p = props[row.idx];
@@ -985,10 +1103,16 @@ export function FieldEditor({ value, width, height, active = true, onChange, onS
985
1103
  const propMeta = metadata?.props?.[p.name];
986
1104
  return (_jsx(PropRow, { prop: p, selected: isSelected, activeField: isSelected && focusLevel === 'field' ? activeField : null, textCursor: textCursor, valueCursor: valueCursor, cursorVisible: cursorVisible, editingValue: isSelected ? editingValue : null, valueText: isSelected ? valueText : '', width: innerWidth, rationale: propMeta?.rationale ?? null, rowKey: String(row.idx) }, `prop-${row.idx}`));
987
1105
  }
988
- // slot row
989
1106
  const s = slots[row.idx];
990
1107
  const isSelected = inSlots && row.idx === slotIdx;
991
- return (_jsx(SlotRow, { slot: s, selected: isSelected, activeField: isSelected && focusLevel === 'field' ? activeField : null, textCursor: textCursor, valueCursor: valueCursor, cursorVisible: cursorVisible, editingValue: isSelected ? editingValue : null, valueText: isSelected ? valueText : '', width: innerWidth }, `slot-${row.idx}`));
1108
+ const slotPickerCandidates = isSelected &&
1109
+ editingValue?.mode === 'add' &&
1110
+ activeField === 'allowedComponents' &&
1111
+ projectSlotGraph &&
1112
+ currentComponentName
1113
+ ? computeAllowedComponentCandidates(projectSlotGraph, currentComponentName, slots, s.name)
1114
+ : null;
1115
+ return (_jsx(SlotRow, { slot: s, selected: isSelected, activeField: isSelected && focusLevel === 'field' ? activeField : null, textCursor: textCursor, valueCursor: valueCursor, cursorVisible: cursorVisible, editingValue: isSelected ? editingValue : null, valueText: isSelected ? valueText : '', width: innerWidth, pickerCandidates: slotPickerCandidates, pickerCursor: pickerCursor }, `slot-${row.idx}`));
992
1116
  }) }), sourceOpen &&
993
1117
  !onToggleSourceExternal &&
994
1118
  (() => {
@@ -1003,5 +1127,5 @@ export function FieldEditor({ value, width, height, active = true, onChange, onS
1003
1127
  }
1004
1128
  const lines = src.split('\n').slice(Math.max(0, start - 1), end);
1005
1129
  return (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", paddingX: 1, children: [_jsx(Text, { dimColor: true, bold: true, children: `${headerPath}: lines ${start}–${end}` }), lines.map((ln, i) => (_jsx(Text, { dimColor: true, children: ln }, `source-line-${i}`))), _jsx(Text, { dimColor: true, children: "[s] close \u00B7 [Esc] close" })] }));
1006
- })(), showHelp && (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "Keybindings" }), _jsx(Text, { children: " " }), _jsx(Text, { bold: true, children: "Row navigation" }), _jsx(Text, { children: ' ↑/↓ or j/k move between rows' }), _jsx(Text, { children: ' Enter edit fields on the current row' }), _jsx(Text, { children: ' Esc exit the panel' }), _jsx(Text, { children: " " }), _jsx(Text, { bold: true, children: "Field editing" }), _jsx(Text, { children: ' ↑/↓ or j/k cycle through fields (j/k literal in text inputs)' }), _jsx(Text, { children: ' ←/→ cycle picker values · move cursor in text inputs' }), _jsx(Text, { children: ' Space/Enter toggle required' }), _jsx(Text, { children: ' Type edit description / string default' }), _jsx(Text, { children: ' Ctrl+S save changes' }), _jsx(Text, { children: ' Esc exit field-edit back to the row' }), _jsx(Text, { children: " " }), _jsx(Text, { bold: true, children: "Values / allowedComponents" }), _jsx(Text, { children: ' a / e / r add · edit · remove' }), _jsx(Text, { children: ' ↑↓ or j/k navigate the list' }), _jsx(Text, { children: ' K / J reorder up · down' }), _jsx(Text, { children: " " }), _jsx(Text, { bold: true, children: "Panels" }), _jsx(Text, { children: ' s toggle source-view for the current prop' }), _jsx(Text, { children: ' i toggle prop rationale panel' }), _jsx(Text, { children: ' I toggle component rationale panel' }), _jsx(Text, { children: ' d toggle removed-components panel (wizard final-review)' }), _jsx(Text, { children: ' ? toggle this overlay' }), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: "press ? or Esc to close" })] })), validationError && _jsx(Text, { color: "red", children: '✗ ' + validationError }), _jsx(Text, { dimColor: true, children: modeLabel })] }));
1130
+ })(), showHelp && (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: PALETTE.info, paddingX: 1, children: [_jsx(Text, { bold: true, color: PALETTE.info, children: "Keybindings" }), _jsx(Text, { children: " " }), _jsx(Text, { bold: true, children: "Row navigation" }), _jsx(Text, { children: ' ↑/↓ or j/k move between rows' }), _jsx(Text, { children: ' Enter edit fields on the current row' }), _jsx(Text, { children: ' Esc exit the panel' }), _jsx(Text, { children: " " }), _jsx(Text, { bold: true, children: "Field editing" }), _jsx(Text, { children: ' ↑/↓ or j/k cycle through fields (j/k literal in text inputs)' }), _jsx(Text, { children: ' ←/→ cycle picker values · move cursor in text inputs' }), _jsx(Text, { children: ' Space/Enter toggle required' }), _jsx(Text, { children: ' Type edit description / string default' }), _jsx(Text, { children: ' Ctrl+S save changes' }), _jsx(Text, { children: ' Esc exit field-edit back to the row' }), _jsx(Text, { children: " " }), _jsx(Text, { bold: true, children: "Values / allowedComponents" }), _jsx(Text, { children: ' a / e / r add · edit · remove' }), _jsx(Text, { children: ' ↑↓ or j/k navigate the list' }), _jsx(Text, { children: ' K / J reorder up · down' }), _jsx(Text, { children: " " }), _jsx(Text, { bold: true, children: "Panels" }), _jsx(Text, { children: ' s toggle source-view for the current prop' }), _jsx(Text, { children: ' ' + propRationaleKey.padEnd(16) + ' toggle prop rationale panel' }), _jsx(Text, { children: ' ' + componentRationaleKey.padEnd(16) + ' toggle component rationale panel' }), _jsx(Text, { children: ' ? toggle this overlay' }), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: "press ? or Esc to close" })] })), validationError && _jsx(Text, { color: PALETTE.error, children: '✗ ' + validationError }), _jsx(Text, { dimColor: true, children: modeLabel })] }));
1007
1131
  }