@structured-field/widget-editor 1.2.0 → 1.2.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@structured-field/widget-editor",
3
- "version": "1.2.0",
3
+ "version": "1.2.2",
4
4
  "description": "A lightweight JSON Schema form builder with support for relation fields (ForeignKey, QuerySet) and autocomplete search.",
5
5
  "type": "module",
6
6
  "main": "dist/structured-widget-editor.js",
@@ -24,12 +24,14 @@ export default {
24
24
  initialData: { default: undefined },
25
25
  errors: { type: Object, default: () => ({}) },
26
26
  customEditors: { type: Array, default: () => [] },
27
+ language: { type: String, default: '' },
27
28
  },
28
29
  emits: ['change'],
29
30
  expose: ['getValue'],
30
31
  provide() {
31
32
  return {
32
33
  customEditors: () => this.customEditors,
34
+ language: () => this.language,
33
35
  };
34
36
  },
35
37
  data() {
@@ -1,14 +1,18 @@
1
1
  <template>
2
2
  <div class="sf-field sf-field-boolean" :class="{ errors: fieldErrors.length }">
3
- <label class="sf-checkbox-label">
4
- <input
5
- type="checkbox"
6
- class="sf-checkbox"
7
- :checked="!!modelValue"
8
- @change="$emit('update:modelValue', $event.target.checked)"
9
- />
10
- {{ title }}
11
- </label>
3
+ <div class="sf-boolean-row">
4
+ <label class="sf-checkbox-label">
5
+ <input
6
+ type="checkbox"
7
+ class="sf-checkbox"
8
+ :checked="!!modelValue"
9
+ @change="$emit('update:modelValue', $event.target.checked)"
10
+ />
11
+ {{ title }}
12
+ <span v-if="isNullable && isNullValue" class="sf-null-badge">null</span>
13
+ </label>
14
+ <button v-if="isNullable && !isNullValue" type="button" class="sf-null-clear-btn" title="Set to null" @click="$emit('update:modelValue', null)">&#x2715;</button>
15
+ </div>
12
16
  <ul v-if="fieldErrors.length" class="errorlist">
13
17
  <li v-for="(err, i) in fieldErrors" :key="i">{{ err }}</li>
14
18
  </ul>
@@ -29,6 +33,12 @@ export default {
29
33
  title() {
30
34
  return this.schema.title || this.humanize(this.path[this.path.length - 1]) || '';
31
35
  },
36
+ isNullable() {
37
+ return !!this.schema._nullable;
38
+ },
39
+ isNullValue() {
40
+ return this.modelValue === null || this.modelValue === undefined;
41
+ },
32
42
  fieldErrors() {
33
43
  if (!this.form || !this.form.getErrorsForPath) return [];
34
44
  return this.form.getErrorsForPath(this.path);
@@ -0,0 +1,173 @@
1
+ <template>
2
+ <div class="sf-field sf-field-json" :class="{ errors: fieldErrors.length }">
3
+ <span class="sf-label" :class="{ required: isRequired }">{{ title }}</span>
4
+ <div class="sf-json-editor" :class="{ 'sf-json-error': hasErrors }">
5
+ <div class="sf-json-toolbar">
6
+ <span v-if="loadError" class="sf-json-error-msg">{{ loadError }}</span>
7
+ <button v-if="ready" type="button" class="sf-btn sf-btn-sm sf-json-format-btn" @click="format">
8
+ Format
9
+ </button>
10
+ </div>
11
+ <div ref="aceContainer" class="sf-json-ace-container" />
12
+ <textarea
13
+ v-if="!ready"
14
+ class="sf-input sf-textarea sf-json-textarea-fallback"
15
+ :value="rawValue"
16
+ spellcheck="false"
17
+ @input="onFallbackInput"
18
+ />
19
+ </div>
20
+ <ul v-if="fieldErrors.length" class="errorlist">
21
+ <li v-for="(err, i) in fieldErrors" :key="i">{{ err }}</li>
22
+ </ul>
23
+ </div>
24
+ </template>
25
+
26
+ <script>
27
+ const ACE_CDN = 'https://cdnjs.cloudflare.com/ajax/libs/ace/1.37.5/ace.min.js';
28
+
29
+ function loadAce() {
30
+ if (window.ace) return Promise.resolve(window.ace);
31
+ return new Promise((resolve, reject) => {
32
+ const existing = document.querySelector(`script[src="${ACE_CDN}"]`);
33
+ if (existing) {
34
+ existing.addEventListener('load', () => resolve(window.ace));
35
+ existing.addEventListener('error', reject);
36
+ return;
37
+ }
38
+ const s = document.createElement('script');
39
+ s.src = ACE_CDN;
40
+ s.async = true;
41
+ s.onload = () => resolve(window.ace);
42
+ s.onerror = () => reject(new Error('Failed to load Ace Editor'));
43
+ document.head.appendChild(s);
44
+ });
45
+ }
46
+
47
+ export default {
48
+ name: 'JsonEditor',
49
+ props: {
50
+ schema: { type: Object, required: true },
51
+ modelValue: { default: null },
52
+ path: { type: Array, default: () => [] },
53
+ form: { type: Object, default: null },
54
+ },
55
+ emits: ['update:modelValue'],
56
+ data() {
57
+ return {
58
+ rawValue: this.modelValue != null ? JSON.stringify(this.modelValue, null, 2) : '{}',
59
+ ready: false,
60
+ hasErrors: false,
61
+ loadError: null,
62
+ _editor: null,
63
+ _silent: false,
64
+ };
65
+ },
66
+ computed: {
67
+ title() {
68
+ return this.schema.title || this.humanize(this.path[this.path.length - 1]) || '';
69
+ },
70
+ isRequired() {
71
+ if (this.path.length < 2 || !this.form) return false;
72
+ const parentPath = this.path.slice(0, -1);
73
+ const fieldName = this.path[this.path.length - 1];
74
+ const parentSchema = this.form.getSchemaAtPath(parentPath);
75
+ return parentSchema && Array.isArray(parentSchema.required) && parentSchema.required.includes(fieldName);
76
+ },
77
+ fieldErrors() {
78
+ if (!this.form || !this.form.getErrorsForPath) return [];
79
+ return this.form.getErrorsForPath(this.path);
80
+ },
81
+ },
82
+ watch: {
83
+ modelValue(val) {
84
+ if (this._silent) return;
85
+ const external = val != null ? JSON.stringify(val, null, 2) : '{}';
86
+ try {
87
+ if (JSON.stringify(JSON.parse(this.rawValue)) !== JSON.stringify(val)) {
88
+ this.rawValue = external;
89
+ if (this._editor) {
90
+ this._silent = true;
91
+ this._editor.setValue(external, -1);
92
+ this._silent = false;
93
+ }
94
+ }
95
+ } catch { /* rawValue is invalid, leave it */ }
96
+ },
97
+ },
98
+ async mounted() {
99
+ try {
100
+ const ace = await loadAce();
101
+ const container = this.$refs.aceContainer;
102
+ if (!container) return;
103
+
104
+ const editor = ace.edit(container);
105
+ editor.setTheme(this._isDark() ? 'ace/theme/one_dark' : 'ace/theme/chrome');
106
+ editor.session.setMode('ace/mode/json');
107
+ editor.session.setTabSize(2);
108
+ editor.session.setUseSoftTabs(true);
109
+ editor.setShowPrintMargin(false);
110
+ editor.setOption('minLines', 5);
111
+ editor.setOption('maxLines', 20);
112
+ editor.setValue(this.rawValue, -1);
113
+ editor.$blockScrolling = Infinity;
114
+
115
+ editor.session.on('change', () => {
116
+ if (this._silent) return;
117
+ const text = editor.getValue();
118
+ this.rawValue = text;
119
+ try {
120
+ const parsed = JSON.parse(text);
121
+ this.hasErrors = false;
122
+ this._silent = true;
123
+ this.$emit('update:modelValue', parsed);
124
+ this._silent = false;
125
+ } catch {
126
+ this.hasErrors = true;
127
+ }
128
+ });
129
+
130
+ // Reflect annotation (lint) errors on hasErrors
131
+ editor.session.on('changeAnnotation', () => {
132
+ const annotations = editor.session.getAnnotations();
133
+ this.hasErrors = annotations.some(a => a.type === 'error');
134
+ });
135
+
136
+ this._editor = editor;
137
+ this.ready = true;
138
+ } catch (err) {
139
+ this.loadError = `Editor unavailable: ${err.message}`;
140
+ }
141
+ },
142
+ beforeUnmount() {
143
+ if (this._editor) {
144
+ this._editor.destroy();
145
+ this._editor = null;
146
+ }
147
+ },
148
+ methods: {
149
+ humanize(str) {
150
+ if (!str) return '';
151
+ return str.replace(/_/g, ' ').replace(/([a-z])([A-Z])/g, '$1 $2').replace(/^./, s => s.toUpperCase());
152
+ },
153
+ _isDark() {
154
+ return document.documentElement.dataset.colorScheme === 'dark' ||
155
+ window.matchMedia?.('(prefers-color-scheme: dark)').matches;
156
+ },
157
+ format() {
158
+ if (!this._editor) return;
159
+ try {
160
+ const formatted = JSON.stringify(JSON.parse(this._editor.getValue()), null, 2);
161
+ this._silent = true;
162
+ this._editor.setValue(formatted, -1);
163
+ this._silent = false;
164
+ this.rawValue = formatted;
165
+ } catch { /* invalid JSON */ }
166
+ },
167
+ onFallbackInput(e) {
168
+ this.rawValue = e.target.value;
169
+ try { this.$emit('update:modelValue', JSON.parse(this.rawValue)); } catch { /* ignore */ }
170
+ },
171
+ },
172
+ };
173
+ </script>
@@ -1,7 +1,7 @@
1
1
  <template>
2
2
  <div class="sf-nullable" :class="{ errors: fieldErrors.length }">
3
3
  <div class="sf-nullable-header">
4
- <label class="sf-label" :class="{ required: isRequired }">{{ title }}</label>
4
+ <span class="sf-label" :class="{ required: isRequired }">{{ title }}</span>
5
5
  <button type="button" :class="toggleClass" @click="toggle">
6
6
  <template v-if="isNull">
7
7
  <SfIcon name="plus" /> Add
@@ -1,15 +1,22 @@
1
1
  <template>
2
2
  <div class="sf-field" :class="{ errors: fieldErrors.length }">
3
- <label class="sf-label" :class="{ required: isRequired }">{{ title }}</label>
4
- <input
5
- type="number"
6
- class="sf-input"
7
- :step="schema.type === 'integer' ? '1' : 'any'"
8
- :min="schema.minimum != null ? String(schema.minimum) : undefined"
9
- :max="schema.maximum != null ? String(schema.maximum) : undefined"
10
- :value="modelValue != null ? modelValue : ''"
11
- @input="onInput"
12
- />
3
+ <span class="sf-label" :class="{ required: isRequired }">
4
+ {{ title }}
5
+ <span v-if="isNullable && isNullValue" class="sf-null-badge">null</span>
6
+ </span>
7
+ <div :class="isNullable ? 'sf-input-row' : null">
8
+ <input
9
+ type="number"
10
+ class="sf-input"
11
+ :step="schema.type === 'integer' ? '1' : 'any'"
12
+ :min="schema.minimum != null ? String(schema.minimum) : undefined"
13
+ :max="schema.maximum != null ? String(schema.maximum) : undefined"
14
+ :value="isNullValue ? '' : modelValue"
15
+ :placeholder="isNullValue ? 'null' : undefined"
16
+ @input="onInput"
17
+ />
18
+ <button v-if="isNullable && !isNullValue" type="button" class="sf-null-clear-btn" title="Set to null" @click="$emit('update:modelValue', null)">&#x2715;</button>
19
+ </div>
13
20
  <ul v-if="fieldErrors.length" class="errorlist">
14
21
  <li v-for="(err, i) in fieldErrors" :key="i">{{ err }}</li>
15
22
  </ul>
@@ -37,6 +44,12 @@ export default {
37
44
  const parentSchema = this.form.getSchemaAtPath(parentPath);
38
45
  return parentSchema && Array.isArray(parentSchema.required) && parentSchema.required.includes(fieldName);
39
46
  },
47
+ isNullable() {
48
+ return !!this.schema._nullable;
49
+ },
50
+ isNullValue() {
51
+ return this.modelValue === null || this.modelValue === undefined;
52
+ },
40
53
  fieldErrors() {
41
54
  if (!this.form || !this.form.getErrorsForPath) return [];
42
55
  return this.form.getErrorsForPath(this.path);
@@ -1,6 +1,6 @@
1
1
  <template>
2
2
  <div class="sf-field sf-relation" :class="{ errors: fieldErrors.length }" ref="root">
3
- <label class="sf-label" :class="{ required: isRequired }">{{ title }}</label>
3
+ <span class="sf-label" :class="{ required: isRequired }">{{ title }}</span>
4
4
  <div class="sf-relation-wrapper">
5
5
  <!-- Selected items -->
6
6
  <div v-if="selected.length" class="sf-relation-selected">
@@ -46,6 +46,9 @@ import SfIcon from './SfIcon.vue';
46
46
  export default {
47
47
  name: 'RelationEditor',
48
48
  components: { SfIcon },
49
+ inject: {
50
+ language: { from: 'language', default: () => () => '' },
51
+ },
49
52
  props: {
50
53
  schema: { type: Object, required: true },
51
54
  modelValue: { default: null },
@@ -151,6 +154,8 @@ export default {
151
154
  const url = new URL(this.searchUrl, window.location.origin);
152
155
  url.searchParams.set('_q', query || '');
153
156
  url.searchParams.set('page', String(page));
157
+ const lang = typeof this.language === 'function' ? this.language() : this.language;
158
+ if (lang) url.searchParams.set('_lang', lang);
154
159
 
155
160
  const response = await fetch(url, { credentials: 'same-origin' });
156
161
  if (!response.ok) return;
@@ -32,6 +32,7 @@ import ArrayEditor from './ArrayEditor.vue';
32
32
  import NullableEditor from './NullableEditor.vue';
33
33
  import UnionEditor from './UnionEditor.vue';
34
34
  import RelationEditor from './RelationEditor.vue';
35
+ import JsonEditor from './JsonEditor.vue';
35
36
  import WebComponentWrapper from './WebComponentWrapper.vue';
36
37
 
37
38
  const MAX_DEPTH = 12;
@@ -49,6 +50,7 @@ export default {
49
50
  NullableEditor,
50
51
  UnionEditor,
51
52
  RelationEditor,
53
+ JsonEditor,
52
54
  WebComponentWrapper,
53
55
  },
54
56
  inject: {
@@ -92,8 +94,9 @@ export default {
92
94
  if (schema.oneOf && schema.discriminator) return 'UnionEditor';
93
95
  if ('const' in schema) return 'HiddenEditor';
94
96
  if (schema.enum && schema.enum.length === 1 && schema.type === 'string') return 'HiddenEditor';
95
- if (schema._nullable) return 'NullableEditor';
97
+ if (schema._nullable && (schema.type === 'object' || schema.type === 'array')) return 'NullableEditor';
96
98
  if (schema.type === 'object' && schema.properties) return 'ObjectEditor';
99
+ if (schema.type === 'object') return 'JsonEditor';
97
100
  if (schema.type === 'array') return 'ArrayEditor';
98
101
  if (schema.enum) return 'SelectEditor';
99
102
  if (schema.type === 'boolean') return 'BooleanEditor';
@@ -1,15 +1,22 @@
1
1
  <template>
2
2
  <div class="sf-field" :class="{ errors: fieldErrors.length }">
3
- <label class="sf-label" :class="{ required: isRequired }">{{ title }}</label>
4
- <select
5
- class="sf-input sf-select"
6
- :value="modelValue != null ? String(modelValue) : ''"
7
- @change="$emit('update:modelValue', $event.target.value)"
8
- >
9
- <option v-for="opt in (schema.enum || [])" :key="opt" :value="String(opt)">
10
- {{ opt }}
11
- </option>
12
- </select>
3
+ <span class="sf-label" :class="{ required: isRequired }">
4
+ {{ title }}
5
+ <span v-if="isNullable && isNullValue" class="sf-null-badge">null</span>
6
+ </span>
7
+ <div :class="isNullable ? 'sf-input-row' : null">
8
+ <select
9
+ class="sf-input sf-select"
10
+ :value="isNullValue ? '' : (modelValue != null ? String(modelValue) : '')"
11
+ @change="$emit('update:modelValue', $event.target.value)"
12
+ >
13
+ <option v-if="isNullable && isNullValue" value="" disabled selected>null</option>
14
+ <option v-for="opt in (schema.enum || [])" :key="opt" :value="String(opt)">
15
+ {{ opt }}
16
+ </option>
17
+ </select>
18
+ <button v-if="isNullable && !isNullValue" type="button" class="sf-null-clear-btn" title="Set to null" @click="$emit('update:modelValue', null)">&#x2715;</button>
19
+ </div>
13
20
  <ul v-if="fieldErrors.length" class="errorlist">
14
21
  <li v-for="(err, i) in fieldErrors" :key="i">{{ err }}</li>
15
22
  </ul>
@@ -37,6 +44,12 @@ export default {
37
44
  const parentSchema = this.form.getSchemaAtPath(parentPath);
38
45
  return parentSchema && Array.isArray(parentSchema.required) && parentSchema.required.includes(fieldName);
39
46
  },
47
+ isNullable() {
48
+ return !!this.schema._nullable;
49
+ },
50
+ isNullValue() {
51
+ return this.modelValue === null || this.modelValue === undefined;
52
+ },
40
53
  fieldErrors() {
41
54
  if (!this.form || !this.form.getErrorsForPath) return [];
42
55
  return this.form.getErrorsForPath(this.path);
@@ -1,22 +1,28 @@
1
1
  <template>
2
2
  <div class="sf-field" :class="{ errors: fieldErrors.length }">
3
- <label class="sf-label" :class="{ required: isRequired }">{{ title }}</label>
4
- <textarea
5
- v-if="isLong"
6
- class="sf-input sf-textarea"
7
- rows="3"
8
- :value="modelValue"
9
- :placeholder="schema.placeholder || ''"
10
- @input="$emit('update:modelValue', $event.target.value)"
11
- />
12
- <input
13
- v-else
14
- type="text"
15
- class="sf-input"
16
- :value="modelValue != null ? String(modelValue) : ''"
17
- :placeholder="schema.placeholder || ''"
18
- @input="$emit('update:modelValue', $event.target.value)"
19
- />
3
+ <span class="sf-label" :class="{ required: isRequired }">
4
+ {{ title }}
5
+ <span v-if="isNullable && isNullValue" class="sf-null-badge">null</span>
6
+ </span>
7
+ <div :class="isNullable ? 'sf-input-row' : null">
8
+ <textarea
9
+ v-if="isLong"
10
+ class="sf-input sf-textarea"
11
+ rows="3"
12
+ :value="isNullValue ? '' : modelValue"
13
+ :placeholder="isNullValue ? 'null' : (schema.placeholder || '')"
14
+ @input="$emit('update:modelValue', $event.target.value)"
15
+ />
16
+ <input
17
+ v-else
18
+ type="text"
19
+ class="sf-input"
20
+ :value="isNullValue ? '' : (modelValue != null ? String(modelValue) : '')"
21
+ :placeholder="isNullValue ? 'null' : (schema.placeholder || '')"
22
+ @input="$emit('update:modelValue', $event.target.value)"
23
+ />
24
+ <button v-if="isNullable && !isNullValue" type="button" class="sf-null-clear-btn" title="Set to null" @click="$emit('update:modelValue', null)">&#x2715;</button>
25
+ </div>
20
26
  <ul v-if="fieldErrors.length" class="errorlist">
21
27
  <li v-for="(err, i) in fieldErrors" :key="i">{{ err }}</li>
22
28
  </ul>
@@ -47,6 +53,12 @@ export default {
47
53
  isLong() {
48
54
  return this.schema.maxLength > 255 || this.schema.format === 'textarea';
49
55
  },
56
+ isNullable() {
57
+ return !!this.schema._nullable;
58
+ },
59
+ isNullValue() {
60
+ return this.modelValue === null || this.modelValue === undefined;
61
+ },
50
62
  fieldErrors() {
51
63
  if (!this.form || !this.form.getErrorsForPath) return [];
52
64
  return this.form.getErrorsForPath(this.path);
@@ -1,7 +1,7 @@
1
1
  <template>
2
2
  <div class="sf-union">
3
3
  <div class="sf-field">
4
- <label class="sf-label">{{ title }}</label>
4
+ <span class="sf-label">{{ title }}</span>
5
5
  <select class="sf-input sf-select" :value="currentType" @change="onTypeChange">
6
6
  <option v-for="key in typeKeys" :key="key" :value="key">{{ humanize(key) }}</option>
7
7
  </select>
@@ -131,7 +131,8 @@
131
131
  border: 1px solid var(--border-color, #ccc);
132
132
  border-radius: 4px;
133
133
  background: var(--darkened-bg, #f8f8f8);
134
- overflow: hidden;
134
+ overflow: visible;
135
+ position: relative;
135
136
  }
136
137
 
137
138
  .sf-array-item {
@@ -156,6 +157,7 @@
156
157
  padding: 4px 8px;
157
158
  background: var(--darkened-bg, #f0f0f0);
158
159
  border-bottom: 1px solid var(--border-color, #ccc);
160
+ border-radius: 4px 4px 0 0;
159
161
  }
160
162
 
161
163
  .sf-array-item-left {
@@ -204,6 +206,63 @@
204
206
  }
205
207
  }
206
208
 
209
+ // --- Inline nullable controls ---
210
+ .sf-null-badge {
211
+ display: inline-block;
212
+ font-size: 0.65rem;
213
+ font-weight: 600;
214
+ text-transform: uppercase;
215
+ letter-spacing: 0.04em;
216
+ color: var(--body-quiet-color, #888);
217
+ background: var(--darkened-bg, #f0f0f0);
218
+ border: 1px solid var(--border-color, #ccc);
219
+ border-radius: 3px;
220
+ padding: 1px 5px;
221
+ vertical-align: middle;
222
+ margin-left: 4px;
223
+ }
224
+
225
+ .sf-input-row {
226
+ display: flex;
227
+ align-items: stretch;
228
+ gap: 4px;
229
+
230
+ .sf-input {
231
+ flex: 1;
232
+ min-width: 0;
233
+ }
234
+ }
235
+
236
+ .sf-boolean-row {
237
+ display: flex;
238
+ align-items: center;
239
+ gap: 6px;
240
+ }
241
+
242
+ .sf-null-clear-btn {
243
+ flex-shrink: 0;
244
+ display: inline-flex;
245
+ align-items: center;
246
+ justify-content: center;
247
+ width: 26px;
248
+ height: 26px;
249
+ padding: 0;
250
+ border: 1px solid var(--border-color, #ccc);
251
+ border-radius: 4px;
252
+ background: transparent;
253
+ cursor: pointer;
254
+ color: var(--body-quiet-color, #888);
255
+ font-size: 0.75rem;
256
+ line-height: 1;
257
+ align-self: center;
258
+
259
+ &:hover {
260
+ background: var(--error-bg, #fff2f2);
261
+ border-color: var(--error-fg, #ba2121);
262
+ color: var(--error-fg, #ba2121);
263
+ }
264
+ }
265
+
207
266
  // --- Nullable editor ---
208
267
  .sf-nullable {
209
268
  margin-bottom: 12px;
@@ -232,6 +291,63 @@
232
291
  }
233
292
  }
234
293
 
294
+ // --- JSON editor ---
295
+ .sf-field-json {
296
+ .sf-json-editor {
297
+ position: relative;
298
+ border: 1px solid var(--border-color, #ccc);
299
+ border-radius: 4px;
300
+ overflow: hidden;
301
+
302
+ &.sf-json-error {
303
+ border-color: var(--error-fg, #ba2121);
304
+ }
305
+ }
306
+
307
+ .sf-json-toolbar {
308
+ display: flex;
309
+ align-items: center;
310
+ justify-content: flex-end;
311
+ gap: 8px;
312
+ padding: 4px 6px;
313
+ background: var(--darkened-bg, #f8f8f8);
314
+ border-bottom: 1px solid var(--border-color, #ccc);
315
+ }
316
+
317
+ .sf-json-error-msg {
318
+ font-size: 0.72rem;
319
+ color: var(--error-fg, #ba2121);
320
+ flex: 1;
321
+ white-space: nowrap;
322
+ overflow: hidden;
323
+ text-overflow: ellipsis;
324
+ }
325
+
326
+ .sf-json-format-btn {
327
+ flex-shrink: 0;
328
+ }
329
+
330
+ .sf-json-ace-container {
331
+ width: 100%;
332
+ font-size: 0.8rem;
333
+ // Ace sets its own height via maxLines/minLines
334
+ .ace_editor {
335
+ font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace !important;
336
+ font-size: 0.8rem !important;
337
+ }
338
+ }
339
+
340
+ .sf-json-textarea-fallback {
341
+ width: 100%;
342
+ min-height: 120px;
343
+ font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
344
+ font-size: 0.8rem;
345
+ border: none;
346
+ border-radius: 0;
347
+ resize: vertical;
348
+ }
349
+ }
350
+
235
351
  // --- Union editor ---
236
352
  .sf-union {
237
353
  margin-bottom: 12px;