@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.
- package/README.md +64 -2
- package/dist/package.json +3 -2
- package/dist/src/analyze/command.d.ts +11 -0
- package/dist/src/analyze/command.js +298 -3
- package/dist/src/analyze/composite-closure.d.ts +25 -0
- package/dist/src/analyze/composite-closure.js +0 -0
- package/dist/src/analyze/composition/agent-parser/author-prompt.d.ts +4 -0
- package/dist/src/analyze/composition/agent-parser/author-prompt.js +22 -0
- package/dist/src/analyze/composition/agent-parser/extract-parser.d.ts +8 -0
- package/dist/src/analyze/composition/agent-parser/extract-parser.js +44 -0
- package/dist/src/analyze/composition/agent-parser/load-prompt.d.ts +7 -0
- package/dist/src/analyze/composition/agent-parser/load-prompt.js +31 -0
- package/dist/src/analyze/composition/agent-parser/resolve-via-parser.d.ts +46 -0
- package/dist/src/analyze/composition/agent-parser/resolve-via-parser.js +88 -0
- package/dist/src/analyze/composition/agent-parser/sandbox.d.ts +45 -0
- package/dist/src/analyze/composition/agent-parser/sandbox.js +187 -0
- package/dist/src/analyze/composition/apply-mapping.d.ts +22 -0
- package/dist/src/analyze/composition/apply-mapping.js +67 -0
- package/dist/src/analyze/composition/candidate-critic-agent.d.ts +7 -0
- package/dist/src/analyze/composition/candidate-critic-agent.js +39 -0
- package/dist/src/analyze/composition/candidate-critic.d.ts +32 -0
- package/dist/src/analyze/composition/candidate-critic.js +63 -0
- package/dist/src/analyze/composition/candidate-files.d.ts +34 -0
- package/dist/src/analyze/composition/candidate-files.js +131 -0
- package/dist/src/analyze/composition/composition-cache-key.d.ts +19 -0
- package/dist/src/analyze/composition/composition-cache-key.js +19 -0
- package/dist/src/analyze/composition/interchange-schema.d.ts +37 -0
- package/dist/src/analyze/composition/interchange-schema.js +61 -0
- package/dist/src/analyze/composition/merge-edges.d.ts +19 -0
- package/dist/src/analyze/composition/merge-edges.js +50 -0
- package/dist/src/analyze/composition/parse-map-edges.d.ts +16 -0
- package/dist/src/analyze/composition/parse-map-edges.js +55 -0
- package/dist/src/analyze/composition/resolve-mapping-cli.d.ts +23 -0
- package/dist/src/analyze/composition/resolve-mapping-cli.js +29 -0
- package/dist/src/analyze/composition/resolve-mapping.d.ts +59 -0
- package/dist/src/analyze/composition/resolve-mapping.js +94 -0
- package/dist/src/analyze/cycle-detection.d.ts +27 -0
- package/dist/src/analyze/cycle-detection.js +234 -0
- package/dist/src/analyze/cycle-view.d.ts +7 -0
- package/dist/src/analyze/cycle-view.js +28 -0
- package/dist/src/analyze/fuzzy-search.d.ts +3 -0
- package/dist/src/analyze/fuzzy-search.js +55 -0
- package/dist/src/analyze/issue-inheritance.d.ts +12 -0
- package/dist/src/analyze/issue-inheritance.js +108 -0
- package/dist/src/analyze/lineage.d.ts +29 -0
- package/dist/src/analyze/lineage.js +0 -0
- package/dist/src/analyze/scope-gate-cascade.d.ts +11 -0
- package/dist/src/analyze/scope-gate-cascade.js +170 -0
- package/dist/src/analyze/search-neighborhood.d.ts +3 -0
- package/dist/src/analyze/search-neighborhood.js +56 -0
- package/dist/src/analyze/select/tui/App.js +5 -16
- package/dist/src/analyze/select/tui/components/ComponentRationalePanel.d.ts +0 -4
- package/dist/src/analyze/select/tui/components/ComponentRationalePanel.js +3 -15
- package/dist/src/analyze/select/tui/components/FieldEditor.d.ts +29 -38
- package/dist/src/analyze/select/tui/components/FieldEditor.js +309 -185
- package/dist/src/analyze/select/tui/components/FinalizeDialog.d.ts +13 -1
- package/dist/src/analyze/select/tui/components/FinalizeDialog.js +23 -2
- package/dist/src/analyze/select/tui/components/GotoBanner.d.ts +31 -0
- package/dist/src/analyze/select/tui/components/GotoBanner.js +30 -0
- package/dist/src/analyze/select/tui/components/GroupedSidebar.d.ts +91 -0
- package/dist/src/analyze/select/tui/components/GroupedSidebar.js +497 -0
- package/dist/src/analyze/select/tui/components/HelpOverlay.d.ts +13 -1
- package/dist/src/analyze/select/tui/components/HelpOverlay.js +9 -3
- package/dist/src/analyze/select/tui/components/JsonEditor.js +3 -3
- package/dist/src/analyze/select/tui/components/LineagePanel.d.ts +14 -0
- package/dist/src/analyze/select/tui/components/LineagePanel.js +22 -0
- package/dist/src/analyze/select/tui/components/RationalePanel.d.ts +0 -7
- package/dist/src/analyze/select/tui/components/RationalePanel.js +3 -15
- package/dist/src/analyze/select/tui/components/Sidebar.d.ts +0 -6
- package/dist/src/analyze/select/tui/components/Sidebar.js +11 -27
- package/dist/src/analyze/select/tui/components/StatusBar.js +2 -1
- package/dist/src/analyze/select/tui/components/removed-components-text.d.ts +7 -0
- package/dist/src/analyze/select/tui/components/removed-components-text.js +11 -0
- package/dist/src/analyze/select/tui/hooks/useImmediateInput.d.ts +1 -5
- package/dist/src/analyze/select/tui/hooks/useImmediateInput.js +8 -10
- package/dist/src/analyze/select/tui/theme.d.ts +15 -0
- package/dist/src/analyze/select/tui/theme.js +28 -0
- package/dist/src/analyze/selection-cascade.d.ts +3 -0
- package/dist/src/analyze/selection-cascade.js +19 -0
- package/dist/src/analyze/slot-graph.d.ts +11 -0
- package/dist/src/analyze/slot-graph.js +17 -0
- package/dist/src/apply/api-client.d.ts +2 -1
- package/dist/src/apply/api-client.js +40 -6
- package/dist/src/apply/command.d.ts +17 -1
- package/dist/src/apply/command.js +69 -11
- package/dist/src/apply/error-parser.d.ts +16 -0
- package/dist/src/apply/error-parser.js +117 -0
- package/dist/src/apply/tui/ServerPreviewView.js +3 -1
- package/dist/src/credentials-store.d.ts +6 -0
- package/dist/src/credentials-store.js +5 -1
- package/dist/src/generate/agent-runner.d.ts +6 -0
- package/dist/src/generate/agent-runner.js +24 -8
- package/dist/src/import/command.js +69 -31
- package/dist/src/import/cycle-auto-reject.d.ts +4 -0
- package/dist/src/import/cycle-auto-reject.js +14 -0
- package/dist/src/import/orchestrator.d.ts +20 -7
- package/dist/src/import/orchestrator.js +139 -35
- package/dist/src/import/strip-allowed-components.d.ts +18 -0
- package/dist/src/import/strip-allowed-components.js +22 -0
- package/dist/src/import/tui/CustomPromptBanner.d.ts +0 -5
- package/dist/src/import/tui/CustomPromptBanner.js +2 -1
- package/dist/src/import/tui/WizardApp.d.ts +10 -74
- package/dist/src/import/tui/WizardApp.js +234 -246
- package/dist/src/import/tui/ai-flag.d.ts +5 -0
- package/dist/src/import/tui/ai-flag.js +6 -0
- package/dist/src/import/tui/autocomplete.d.ts +5 -0
- package/dist/src/import/tui/autocomplete.js +32 -0
- package/dist/src/import/tui/components/AutoFilterBanner.d.ts +10 -0
- package/dist/src/import/tui/components/AutoFilterBanner.js +19 -0
- package/dist/src/import/tui/components/CounterStrip.d.ts +12 -0
- package/dist/src/import/tui/components/CounterStrip.js +12 -0
- package/dist/src/import/tui/components/LegendEntry.d.ts +2 -0
- package/dist/src/import/tui/components/LegendEntry.js +6 -0
- package/dist/src/import/tui/cycle-panel-scroll.d.ts +8 -0
- package/dist/src/import/tui/cycle-panel-scroll.js +33 -0
- package/dist/src/import/tui/final-review-host.d.ts +3 -7
- package/dist/src/import/tui/final-review-host.js +9 -4
- package/dist/src/import/tui/group-collapse.d.ts +2 -0
- package/dist/src/import/tui/group-collapse.js +11 -0
- package/dist/src/import/tui/history.d.ts +22 -0
- package/dist/src/import/tui/history.js +53 -0
- package/dist/src/import/tui/hooks/useLineage.d.ts +32 -0
- package/dist/src/import/tui/hooks/useLineage.js +50 -0
- package/dist/src/import/tui/hooks/useOverlayPanel.d.ts +13 -0
- package/dist/src/import/tui/hooks/useOverlayPanel.js +27 -0
- package/dist/src/import/tui/lineage-layout.d.ts +75 -0
- package/dist/src/import/tui/lineage-layout.js +82 -0
- package/dist/src/import/tui/runLivePreview.d.ts +11 -0
- package/dist/src/import/tui/runLivePreview.js +7 -1
- package/dist/src/import/tui/scope-gate-columns.d.ts +31 -0
- package/dist/src/import/tui/scope-gate-columns.js +94 -0
- package/dist/src/import/tui/scope-gate-host.d.ts +3 -1
- package/dist/src/import/tui/scope-gate-host.js +11 -3
- package/dist/src/import/tui/sidebar-width.d.ts +1 -0
- package/dist/src/import/tui/sidebar-width.js +3 -0
- package/dist/src/import/tui/step-filters.d.ts +19 -0
- package/dist/src/import/tui/step-filters.js +42 -0
- package/dist/src/import/tui/steps/AtomicGenerateReviewStep.d.ts +42 -0
- package/dist/src/import/tui/steps/AtomicGenerateReviewStep.js +726 -0
- package/dist/src/import/tui/steps/AtomicScopeGateStep.d.ts +3 -0
- package/dist/src/import/tui/steps/AtomicScopeGateStep.js +211 -0
- package/dist/src/import/tui/steps/CredentialsStep.js +5 -18
- package/dist/src/import/tui/steps/DoneStep.d.ts +0 -2
- package/dist/src/import/tui/steps/DoneStep.js +3 -2
- package/dist/src/import/tui/steps/ErrorStep.js +2 -1
- package/dist/src/import/tui/steps/GenerateReviewStep.d.ts +19 -25
- package/dist/src/import/tui/steps/GenerateReviewStep.js +1258 -229
- package/dist/src/import/tui/steps/PathValidationStep.js +3 -2
- package/dist/src/import/tui/steps/PreviewStep.js +2 -1
- package/dist/src/import/tui/steps/PushDecisionGateStep.d.ts +0 -7
- package/dist/src/import/tui/steps/PushDecisionGateStep.js +3 -6
- package/dist/src/import/tui/steps/PushingStep.js +2 -1
- package/dist/src/import/tui/steps/RunningStep.d.ts +4 -1
- package/dist/src/import/tui/steps/RunningStep.js +3 -2
- package/dist/src/import/tui/steps/ScopeGateStep.d.ts +24 -0
- package/dist/src/import/tui/steps/ScopeGateStep.js +850 -193
- package/dist/src/import/tui/steps/TokenInputStep.js +2 -1
- package/dist/src/import/tui/steps/WelcomeStep.js +2 -1
- package/dist/src/import/tui/steps/WizardPreviewStep.d.ts +6 -0
- package/dist/src/import/tui/steps/WizardPreviewStep.js +95 -56
- package/dist/src/import/tui/steps/auto-reject-decision.d.ts +7 -0
- package/dist/src/import/tui/steps/auto-reject-decision.js +9 -0
- package/dist/src/import/tui/steps/breaking-change-format.d.ts +2 -0
- package/dist/src/import/tui/steps/breaking-change-format.js +24 -0
- package/dist/src/import/tui/steps/enumerate-cycle-breaks.d.ts +14 -0
- package/dist/src/import/tui/steps/enumerate-cycle-breaks.js +0 -0
- package/dist/src/import/tui/steps/preview-diff.js +48 -5
- package/dist/src/import/tui/useFinalizePreview.d.ts +34 -0
- package/dist/src/import/tui/useFinalizePreview.js +0 -0
- package/dist/src/import/tui/useLivePreview.d.ts +3 -0
- package/dist/src/import/tui/useLivePreview.js +1 -0
- package/dist/src/import/tui/wizard-state-transitions.d.ts +5 -59
- package/dist/src/import/tui/wizard-state-transitions.js +5 -60
- package/dist/src/lib/composition-mode.d.ts +28 -0
- package/dist/src/lib/composition-mode.js +37 -0
- package/dist/src/lib/prompt-overrides.d.ts +38 -0
- package/dist/src/lib/prompt-overrides.js +69 -0
- package/dist/src/print/command.js +19 -1
- package/dist/src/runs/modify-launcher.d.ts +3 -0
- package/dist/src/runs/modify-launcher.js +2 -0
- package/dist/src/runs/replay-helpers.js +1 -0
- package/dist/src/runs/run-picker.js +2 -18
- package/dist/src/runs/store.d.ts +4 -0
- package/dist/src/session/db.d.ts +23 -40
- package/dist/src/session/db.js +110 -138
- package/dist/src/setup/composition-mode-prompt.d.ts +14 -0
- package/dist/src/setup/composition-mode-prompt.js +37 -0
- package/package.json +5 -4
- package/prompts/composition-dir-critic.md +7 -0
- package/prompts/composition-edges.md +8 -0
- package/prompts/composition-parser-repair-empty.md +1 -0
- package/prompts/composition-parser-repair-error.md +2 -0
- 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
|
-
|
|
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:
|
|
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 ?
|
|
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:
|
|
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:
|
|
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:
|
|
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 ?
|
|
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 ?
|
|
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:
|
|
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 ?
|
|
202
|
-
}), editingValue?.mode === 'add' && (_jsxs(Box, { paddingLeft: 2, children: [_jsx(Text, { color:
|
|
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 ?
|
|
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:
|
|
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 ?
|
|
215
|
-
}), editingValue?.mode === 'add' && activeField === 'allowedComponents' && (_jsxs(Box, { paddingLeft: 2, children: [_jsx(Text, { color:
|
|
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
|
-
|
|
242
|
-
|
|
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(
|
|
278
|
-
const [slotIdx, setSlotIdx] = useState(
|
|
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
|
-
|
|
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 ===
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
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 ===
|
|
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
|
-
|
|
628
|
-
|
|
738
|
+
if (valueCursor > 0) {
|
|
739
|
+
setValueCursor((c) => Math.max(0, c - 1));
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
629
742
|
}
|
|
630
|
-
if (arrowDown || input === 'j') {
|
|
631
|
-
|
|
632
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
|
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: `──
|
|
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: `──
|
|
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);
|
|
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 ?
|
|
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, {
|
|
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 ?
|
|
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
|
-
|
|
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:
|
|
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
|
}
|