@developer_tribe/react-builder 1.0.3 → 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/dist/android.svg +43 -0
  2. package/dist/apple.svg +16 -0
  3. package/dist/attributes-editor/Field.d.ts +2 -1
  4. package/dist/attributes-editor/SizeField.d.ts +9 -0
  5. package/dist/build-components/BackgroundImage/BackgroundImageProps.generated.d.ts +1 -0
  6. package/dist/build-components/Button/ButtonProps.generated.d.ts +1 -0
  7. package/dist/build-components/Carousel/CarouselProps.generated.d.ts +1 -0
  8. package/dist/build-components/CarouselButtons/CarouselButtonsProps.generated.d.ts +1 -0
  9. package/dist/build-components/CarouselDots/CarouselDotsProps.generated.d.ts +1 -0
  10. package/dist/build-components/CarouselItem/CarouselItemProps.generated.d.ts +1 -0
  11. package/dist/build-components/CarouselProvider/CarouselProviderProps.generated.d.ts +1 -0
  12. package/dist/build-components/Image/ImageProps.generated.d.ts +1 -0
  13. package/dist/build-components/Onboard/OnboardProps.generated.d.ts +1 -0
  14. package/dist/build-components/OnboardButton/OnboardButtonProps.generated.d.ts +1 -1
  15. package/dist/build-components/OnboardButtons/OnboardButtonsProps.generated.d.ts +1 -0
  16. package/dist/build-components/OnboardDot/OnboardDotProps.generated.d.ts +2 -3
  17. package/dist/build-components/OnboardFooter/OnboardFooterProps.generated.d.ts +1 -0
  18. package/dist/build-components/OnboardImage/OnboardImageProps.generated.d.ts +2 -1
  19. package/dist/build-components/OnboardItem/OnboardItemProps.generated.d.ts +1 -0
  20. package/dist/build-components/OnboardProvider/OnboardProviderProps.generated.d.ts +1 -0
  21. package/dist/build-components/OnboardSubtitle/OnboardSubtitleProps.generated.d.ts +1 -0
  22. package/dist/build-components/OnboardTitle/OnboardTitleProps.generated.d.ts +1 -0
  23. package/dist/build-components/Text/TextProps.generated.d.ts +1 -0
  24. package/dist/build-components/View/ViewProps.generated.d.ts +1 -0
  25. package/dist/build-components/patterns.generated.d.ts +194 -57
  26. package/dist/components/JsonTextEditor.d.ts +9 -0
  27. package/dist/index.cjs.js +5 -5
  28. package/dist/index.cjs.js.map +1 -1
  29. package/dist/index.esm.js +5 -5
  30. package/dist/index.esm.js.map +1 -1
  31. package/dist/pages/tabs/SideTool.d.ts +2 -1
  32. package/dist/store.d.ts +2 -0
  33. package/dist/styles.css +1 -1
  34. package/dist/utils/extractImageStyle.d.ts +2 -1
  35. package/dist/utils/extractViewStyle.d.ts +1 -2
  36. package/dist/utils/selection.d.ts +7 -0
  37. package/dist/utils/useMergedStyle.d.ts +2 -0
  38. package/package.json +2 -5
  39. package/src/.DS_Store +0 -0
  40. package/src/AttributesEditor.tsx +7 -2
  41. package/src/RenderPage.tsx +10 -6
  42. package/src/attributes-editor/Field.tsx +48 -160
  43. package/src/attributes-editor/SizeField.tsx +184 -0
  44. package/src/attributes-editor/SpecialCategorySection.tsx +10 -3
  45. package/src/build-components/BackgroundImage/BackgroundImage.tsx +7 -17
  46. package/src/build-components/BackgroundImage/BackgroundImageProps.generated.ts +1 -0
  47. package/src/build-components/Button/Button.tsx +7 -9
  48. package/src/build-components/Button/ButtonProps.generated.ts +1 -0
  49. package/src/build-components/Carousel/Carousel.tsx +7 -9
  50. package/src/build-components/Carousel/CarouselProps.generated.ts +1 -0
  51. package/src/build-components/CarouselButtons/CarouselButtonsProps.generated.ts +1 -0
  52. package/src/build-components/CarouselDots/CarouselDotsProps.generated.ts +1 -0
  53. package/src/build-components/CarouselItem/CarouselItemProps.generated.ts +1 -0
  54. package/src/build-components/CarouselProvider/CarouselProviderProps.generated.ts +1 -0
  55. package/src/build-components/Image/Image.tsx +11 -18
  56. package/src/build-components/Image/ImageProps.generated.ts +1 -0
  57. package/src/build-components/Image/pattern.json +1 -9
  58. package/src/build-components/Onboard/OnboardProps.generated.ts +1 -0
  59. package/src/build-components/OnboardButton/OnboardButton.tsx +0 -3
  60. package/src/build-components/OnboardButton/OnboardButtonProps.generated.ts +1 -1
  61. package/src/build-components/OnboardButtons/OnboardButtonsProps.generated.ts +1 -0
  62. package/src/build-components/OnboardDot/OnboardDot.tsx +59 -39
  63. package/src/build-components/OnboardDot/OnboardDotProps.generated.ts +2 -3
  64. package/src/build-components/OnboardDot/pattern.json +2 -18
  65. package/src/build-components/OnboardFooter/OnboardFooter.tsx +28 -15
  66. package/src/build-components/OnboardFooter/OnboardFooterProps.generated.ts +1 -0
  67. package/src/build-components/OnboardImage/OnboardImageProps.generated.ts +2 -1
  68. package/src/build-components/OnboardItem/OnboardItem.tsx +1 -11
  69. package/src/build-components/OnboardItem/OnboardItemProps.generated.ts +1 -0
  70. package/src/build-components/OnboardProvider/OnboardProvider.tsx +1 -8
  71. package/src/build-components/OnboardProvider/OnboardProviderProps.generated.ts +1 -0
  72. package/src/build-components/OnboardSubtitle/OnboardSubtitleProps.generated.ts +1 -0
  73. package/src/build-components/OnboardTitle/OnboardTitleProps.generated.ts +1 -0
  74. package/src/build-components/Text/Text.tsx +9 -15
  75. package/src/build-components/Text/TextProps.generated.ts +1 -0
  76. package/src/build-components/View/View.tsx +7 -9
  77. package/src/build-components/View/ViewProps.generated.ts +1 -0
  78. package/src/build-components/View/pattern.json +9 -1
  79. package/src/build-components/patterns.generated.ts +194 -57
  80. package/src/components/Builder.tsx +61 -17
  81. package/src/components/DeviceNavigationBar.tsx +0 -1
  82. package/src/components/EditorHeader.tsx +11 -1
  83. package/src/components/JsonTextEditor.tsx +185 -0
  84. package/src/mockOS/components/MockOSRouter.tsx +6 -0
  85. package/src/mockOS/context/MockOSContext.tsx +0 -5
  86. package/src/mockOS/managers/mockPermissionManager.ts +0 -4
  87. package/src/mockOS/managers/navigationManager.ts +1 -6
  88. package/src/modals/ColorModal.tsx +103 -25
  89. package/src/modals/LocalicationModal.tsx +4 -5
  90. package/src/modals/Modal.tsx +8 -1
  91. package/src/pages/ProjectPage.tsx +7 -1
  92. package/src/pages/tabs/SideTool.tsx +10 -9
  93. package/src/store.ts +5 -0
  94. package/src/styles/base/_global.scss +5 -0
  95. package/src/styles/components/_editor-shell.scss +4 -2
  96. package/src/styles/modals/_color-modal.scss +30 -1
  97. package/src/styles/utilities/_carousel.scss +9 -8
  98. package/src/utils/extractImageStyle.ts +3 -6
  99. package/src/utils/extractTextStyle.ts +2 -81
  100. package/src/utils/extractViewStyle.ts +20 -15
  101. package/src/utils/selection.ts +24 -0
  102. package/src/utils/useMergedStyle.ts +16 -0
@@ -41,9 +41,6 @@ function BuilderComponent({
41
41
  onMoveChildUp,
42
42
  onMoveChildDown,
43
43
  }: BuilderEditorComponentProps) {
44
- if (isNodeNullOrUndefined(node)) {
45
- return <div className="builder__placeholder">Null or undefined</div>;
46
- }
47
44
  if (isNodeString(node)) {
48
45
  return (
49
46
  <div className="builder__text">
@@ -105,7 +102,7 @@ function BuilderComponent({
105
102
  }
106
103
 
107
104
  const nodeData = node as NodeData<NodeDefaultAttribute>;
108
- const rawChildren = nodeData.children;
105
+ const rawChildren = nodeData?.children;
109
106
  const hasArrayChildren = isNodeArray(rawChildren);
110
107
  const children = rawChildren
111
108
  ? hasArrayChildren
@@ -195,16 +192,55 @@ export function Builder({
195
192
 
196
193
  const handleAddChild = useCallback(
197
194
  (type: string) => {
198
- if (
199
- isNodeNullOrUndefined(current) ||
200
- isNodeString(current) ||
201
- isNodeArray(current)
202
- ) {
195
+ const nextChild = createDefaultNode(type);
196
+
197
+ // Root (or selection) can be empty/null-ish: allow creating the first node.
198
+ if (isNodeNullOrUndefined(current)) {
199
+ // If the project itself is empty (or a placeholder string), replace it with the first node.
200
+ if (isNodeNullOrUndefined(data) || isNodeString(data)) {
201
+ setData(nextChild);
202
+ setCurrent(nextChild);
203
+ return;
204
+ }
205
+
206
+ // If the project root is a list, append into it.
207
+ if (Array.isArray(data)) {
208
+ const nextList = [...data, nextChild];
209
+ setData(nextList);
210
+ setCurrent(nextList);
211
+ return;
212
+ }
213
+
214
+ // Otherwise default to adding into the root node.
215
+ const parent = data as NodeData<NodeDefaultAttribute>;
216
+ const updatedParent: NodeData<NodeDefaultAttribute> = {
217
+ ...parent,
218
+ children: appendChild(parent.children, nextChild),
219
+ };
220
+ setData(updatedParent);
221
+ setCurrent(updatedParent);
222
+ return;
223
+ }
224
+
225
+ // If the root is a list, allow adding to it directly.
226
+ if (isNodeArray(current)) {
227
+ const nextList = [...(current as Node[]), nextChild];
228
+ const updatedRoot = replaceNode(data, current, nextList);
229
+ setData(updatedRoot);
230
+ setCurrent(nextList);
231
+ return;
232
+ }
233
+
234
+ // If the root is a placeholder string, allow replacing it with the first node.
235
+ if (isNodeString(current)) {
236
+ if (current === data) {
237
+ setData(nextChild);
238
+ setCurrent(nextChild);
239
+ }
203
240
  return;
204
241
  }
205
242
 
206
243
  const parent = current as NodeData<NodeDefaultAttribute>;
207
- const nextChild = createDefaultNode(type);
208
244
  const updatedParent: NodeData<NodeDefaultAttribute> = {
209
245
  ...parent,
210
246
  children: appendChild(parent.children, nextChild),
@@ -230,7 +266,11 @@ export function Builder({
230
266
  }
231
267
  return (current as NodeData<NodeDefaultAttribute>).type ?? null;
232
268
  }, [current]);
233
- const canAddChild = allowedChildTypes.length > 0;
269
+ const canAddChild =
270
+ allowedChildTypes.length > 0 ||
271
+ data === undefined ||
272
+ data === null ||
273
+ (Array.isArray(data) && data.length === 0);
234
274
 
235
275
  const handleOpenAddModal = useCallback(() => {
236
276
  if (!canAddChild) return;
@@ -373,12 +413,16 @@ export function Builder({
373
413
  }
374
414
 
375
415
  function getAllowedChildTypes(parent: Node): string[] {
376
- if (
377
- isNodeNullOrUndefined(parent) ||
378
- isNodeString(parent) ||
379
- isNodeArray(parent)
380
- )
381
- return [];
416
+ // Treat non-node containers (root empty, root arrays) as "View-like" containers:
417
+ // allow inserting any component type as a child.
418
+ if (isNodeNullOrUndefined(parent)) return [...allcomponentNames];
419
+ if (isNodeArray(parent) && (parent as Node[]).length === 0)
420
+ return [...allcomponentNames];
421
+ if (isNodeString(parent)) {
422
+ // Only allow adding when the string is the root placeholder.
423
+ return parent === data ? [...allcomponentNames] : [];
424
+ }
425
+
382
426
  const parentData = parent as NodeData;
383
427
  const parentType = parentData.type;
384
428
  // Special rule: limit OnboardButtons to OnboardButton only
@@ -37,7 +37,6 @@ export function DeviceNavigationBar({
37
37
  : 'rgba(0, 0, 0, 0.4)';
38
38
 
39
39
  function handleBackButton() {
40
- console.log('handleBackButton', context);
41
40
  if (context) {
42
41
  const canGoBack = context.goBack();
43
42
  // If can't go back, go to launchscreen
@@ -24,9 +24,14 @@ export function EditorHeader({
24
24
  useLogRender('EditorHeader');
25
25
  const [isDevicesModalOpen, setIsDevicesModalOpen] = useState(false);
26
26
  const copiedNode = useRenderStore((s) => s.copiedNode);
27
- const { device: selectedDevice, setDevice } = useRenderStore((s) => ({
27
+ const {
28
+ device: selectedDevice,
29
+ setDevice,
30
+ setCurrent,
31
+ } = useRenderStore((s) => ({
28
32
  device: s.device,
29
33
  setDevice: s.setDevice,
34
+ setCurrent: s.setCurrent,
30
35
  }));
31
36
 
32
37
  function replaceNode(root: Node, target: Node, next: Node): Node {
@@ -67,6 +72,11 @@ export function EditorHeader({
67
72
  copiedNode: null,
68
73
  });
69
74
  setEditorData(updated);
75
+ //TODO: current and editor must be sync!! and tested more
76
+ // Important: selection is stored by reference. After replacing `current` in the tree,
77
+ // we must point selection to the new (cloned) node reference to keep "current node"
78
+ // in sync with what’s rendered/edited.
79
+ setCurrent(cloned);
70
80
  };
71
81
  return (
72
82
  <div
@@ -0,0 +1,185 @@
1
+ import React, { useEffect, useMemo, useState } from 'react';
2
+
3
+ type JsonTextEditorProps = {
4
+ value: unknown;
5
+ onChange?: (next: unknown) => void;
6
+ rootName?: string;
7
+ readOnly?: boolean;
8
+ className?: string;
9
+ };
10
+
11
+ function safeStringify(value: unknown) {
12
+ try {
13
+ return JSON.stringify(value, null, 2) ?? '';
14
+ } catch {
15
+ // Fallback for circular structures or non-serializable values
16
+ return String(value ?? '');
17
+ }
18
+ }
19
+
20
+ export function JsonTextEditor({
21
+ value,
22
+ onChange,
23
+ rootName,
24
+ readOnly = false,
25
+ className,
26
+ }: JsonTextEditorProps) {
27
+ const initialText = useMemo(() => safeStringify(value), [value]);
28
+ const [text, setText] = useState(initialText);
29
+ const [parseError, setParseError] = useState<string | null>(null);
30
+ const [applyError, setApplyError] = useState<string | null>(null);
31
+ const [parsedValue, setParsedValue] = useState<unknown>(value);
32
+
33
+ useEffect(() => {
34
+ setText(initialText);
35
+ setParseError(null);
36
+ setApplyError(null);
37
+ setParsedValue(value);
38
+ }, [initialText, value]);
39
+
40
+ const handleCopy = async () => {
41
+ try {
42
+ await navigator.clipboard.writeText(text);
43
+ } catch {
44
+ // ignore (e.g. non-secure context)
45
+ }
46
+ };
47
+
48
+ const handleFormat = () => {
49
+ try {
50
+ const parsed = JSON.parse(text);
51
+ setText(JSON.stringify(parsed, null, 2));
52
+ setParseError(null);
53
+ setApplyError(null);
54
+ setParsedValue(parsed);
55
+ } catch (e) {
56
+ setParseError(e instanceof Error ? e.message : 'Invalid JSON');
57
+ }
58
+ };
59
+
60
+ const handleApply = () => {
61
+ if (!onChange) return;
62
+ try {
63
+ const parsed = JSON.parse(text);
64
+ setParseError(null);
65
+ setApplyError(null);
66
+ setParsedValue(parsed);
67
+ try {
68
+ onChange(parsed);
69
+ } catch (e) {
70
+ setApplyError(e instanceof Error ? e.message : 'Failed to apply JSON');
71
+ }
72
+ } catch (e) {
73
+ setParseError(e instanceof Error ? e.message : 'Invalid JSON');
74
+ }
75
+ };
76
+
77
+ const headerLabel = rootName ? `${rootName}.json` : 'data.json';
78
+
79
+ return (
80
+ <div
81
+ className={className}
82
+ style={{
83
+ height: '100%',
84
+ width: '100%',
85
+ display: 'flex',
86
+ flexDirection: 'column',
87
+ gap: 10,
88
+ }}
89
+ >
90
+ <div
91
+ style={{
92
+ display: 'flex',
93
+ alignItems: 'center',
94
+ justifyContent: 'space-between',
95
+ gap: 8,
96
+ flexWrap: 'wrap',
97
+ }}
98
+ >
99
+ <div style={{ fontSize: 12, opacity: 0.75 }}>{headerLabel}</div>
100
+ <div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
101
+ <button type="button" className="editor-button" onClick={handleCopy}>
102
+ Copy
103
+ </button>
104
+ <button
105
+ type="button"
106
+ className="editor-button"
107
+ onClick={handleFormat}
108
+ >
109
+ Format
110
+ </button>
111
+ {!readOnly && (
112
+ <button
113
+ type="button"
114
+ className="editor-button"
115
+ onClick={handleApply}
116
+ disabled={!onChange}
117
+ title={onChange ? 'Apply JSON changes' : 'Read only'}
118
+ >
119
+ Apply
120
+ </button>
121
+ )}
122
+ </div>
123
+ </div>
124
+
125
+ <div
126
+ style={{
127
+ display: 'grid',
128
+ gridTemplateColumns: 'minmax(0, 1fr)',
129
+ gap: 10,
130
+ flex: 1,
131
+ minHeight: 0,
132
+ }}
133
+ >
134
+ <textarea
135
+ value={text}
136
+ onChange={(e) => {
137
+ const nextText = e.target.value;
138
+ setText(nextText);
139
+ setApplyError(null);
140
+ if (readOnly) return;
141
+ try {
142
+ const parsed = JSON.parse(nextText);
143
+ setParseError(null);
144
+ setParsedValue(parsed);
145
+ } catch (e) {
146
+ setParseError(e instanceof Error ? e.message : 'Invalid JSON');
147
+ }
148
+ }}
149
+ readOnly={readOnly}
150
+ spellCheck={false}
151
+ style={{
152
+ width: '100%',
153
+ height: '100%',
154
+ minHeight: 320,
155
+ resize: 'none',
156
+ border: '1px solid rgba(0,0,0,0.12)',
157
+ borderRadius: 10,
158
+ padding: 12,
159
+ fontFamily:
160
+ 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
161
+ fontSize: 12,
162
+ lineHeight: 1.5,
163
+ background: 'transparent',
164
+ outline: 'none',
165
+ }}
166
+ />
167
+ </div>
168
+
169
+ {parseError ? (
170
+ <div style={{ fontSize: 12, color: '#d12f2f' }}>
171
+ Invalid JSON: {parseError}
172
+ </div>
173
+ ) : applyError ? (
174
+ <div style={{ fontSize: 12, color: '#d12f2f' }}>
175
+ Could not apply: {applyError}
176
+ </div>
177
+ ) : (
178
+ <div style={{ fontSize: 12, opacity: 0.7 }}>
179
+ Valid JSON ({safeStringify(parsedValue).length.toLocaleString()}{' '}
180
+ chars)
181
+ </div>
182
+ )}
183
+ </div>
184
+ );
185
+ }
@@ -1,6 +1,7 @@
1
1
  import React, { ReactNode, useCallback, useEffect } from 'react';
2
2
  import { useMockOSContext } from '../context/MockOSContext';
3
3
  import { MockLaunchScreenComponent } from './MockLaunchScreenComponent';
4
+ import { useRenderStore } from '../../store';
4
5
  // Note: We might use react-router or similar library in the future for more complex routing
5
6
 
6
7
  interface MockOSRouterProps {
@@ -85,6 +86,7 @@ export function MockOSRouter({
85
86
  appName = 'My App',
86
87
  }: MockOSRouterProps) {
87
88
  const context = useMockOSContext();
89
+ const incForceRender = useRenderStore((s) => s.incForceRender);
88
90
 
89
91
  if (!context) {
90
92
  throw new Error('MockOSRouter must be used within MockOSProvider');
@@ -106,6 +108,10 @@ export function MockOSRouter({
106
108
  return () => clearTimeout(timer);
107
109
  }, [handleLaunchApp]);
108
110
 
111
+ useEffect(() => {
112
+ incForceRender();
113
+ }, [currentRoute, incForceRender]);
114
+
109
115
  return (
110
116
  <div className="mockos-router">
111
117
  <ScreenRenderer
@@ -81,7 +81,6 @@ export function MockOSProvider({
81
81
  ]);
82
82
 
83
83
  const navigation = useCallback((route: RouteType) => {
84
- console.log(`[Mock OS] Navigating to: ${route}`);
85
84
  setCurrentRoute(route);
86
85
 
87
86
  // If navigating from launchscreen and the last item in stack is launchscreen,
@@ -109,12 +108,10 @@ export function MockOSProvider({
109
108
  newStack.pop();
110
109
  const previousRoute = newStack[newStack.length - 1];
111
110
 
112
- console.log(`[Mock OS] Going back to: ${previousRoute.route}`);
113
111
  setCurrentRoute(previousRoute.route);
114
112
  setNavigationStack(newStack);
115
113
  return true;
116
114
  }
117
- console.log('[Mock OS] Cannot go back - at root');
118
115
  return false;
119
116
  }, [navigationStack]);
120
117
 
@@ -129,12 +126,10 @@ export function MockOSProvider({
129
126
  };
130
127
 
131
128
  const handleAllow = () => {
132
- console.log(`[Mock OS] Permission granted: ${permission}`);
133
129
  setPermission(null);
134
130
  };
135
131
 
136
132
  const handleDeny = () => {
137
- console.log(`[Mock OS] Permission denied: ${permission}`);
138
133
  setPermission(null);
139
134
  };
140
135
 
@@ -26,7 +26,6 @@ export class MockPermissionManager {
26
26
  return 'not-determined';
27
27
  }
28
28
 
29
- console.log(`[Mock OS] Permission requested: ${permission}`);
30
29
  // Set permission to trigger modal display
31
30
  this.context.setPermission(permission);
32
31
  // Default behavior: grant all permissions in mock environment
@@ -39,7 +38,6 @@ export class MockPermissionManager {
39
38
  return 'not-determined';
40
39
  }
41
40
 
42
- console.log(`[Mock OS] Permission checked: ${permission}`);
43
41
  return 'granted';
44
42
  }
45
43
 
@@ -48,7 +46,5 @@ export class MockPermissionManager {
48
46
  alert('Opening Settings\n(Mock OS context not available)');
49
47
  return;
50
48
  }
51
-
52
- console.log('[Mock OS] Opening Settings');
53
49
  }
54
50
  }
@@ -31,7 +31,6 @@ export class MockNavigationManager {
31
31
  timestamp: Date.now(),
32
32
  };
33
33
  this.stack.push(item);
34
- console.log('[Mock OS] Navigate to Home', { stack: this.stack });
35
34
  }
36
35
 
37
36
  goToSubscriptions(): void {
@@ -45,7 +44,6 @@ export class MockNavigationManager {
45
44
  timestamp: Date.now(),
46
45
  };
47
46
  this.stack.push(item);
48
- console.log('[Mock OS] Navigate to Subscriptions', { stack: this.stack });
49
47
  }
50
48
 
51
49
  goToLaunchApp(): void {
@@ -59,7 +57,6 @@ export class MockNavigationManager {
59
57
  timestamp: Date.now(),
60
58
  };
61
59
  this.stack.push(item);
62
- console.log('[Mock OS] Navigate to Launch App', { stack: this.stack });
63
60
  }
64
61
 
65
62
  goBack(): boolean {
@@ -70,11 +67,9 @@ export class MockNavigationManager {
70
67
 
71
68
  if (this.stack.length > 0) {
72
69
  const popped = this.stack.pop();
73
- console.log('[Mock OS] Go Back', { popped, stack: this.stack });
74
70
  return true;
75
71
  }
76
72
 
77
- console.log('[Mock OS] Cannot go back - stack is empty');
78
73
  return false;
79
74
  }
80
75
 
@@ -85,7 +80,7 @@ export class MockNavigationManager {
85
80
  clearStack(): void {
86
81
  this.stack = [];
87
82
  if (this.context) {
88
- console.log('[Mock OS] Navigation stack cleared');
83
+ console.info('[Mock OS] Navigation stack cleared');
89
84
  }
90
85
  }
91
86
  }
@@ -65,7 +65,11 @@ const readSavedColors = (): string[] => {
65
65
  const stored = window.localStorage.getItem(SAVED_COLORS_KEY);
66
66
  if (!stored) return [];
67
67
  const parsed = JSON.parse(stored);
68
- return Array.isArray(parsed) ? parsed : [];
68
+ if (!Array.isArray(parsed)) return [];
69
+ return parsed
70
+ .filter((value) => typeof value === 'string')
71
+ .map((value) => value.trim().toLowerCase())
72
+ .filter(Boolean);
69
73
  } catch {
70
74
  return [];
71
75
  }
@@ -143,6 +147,7 @@ export function ColorModal({
143
147
  const [useColorNames, setUseColorNames] = useState(true);
144
148
  const colorInputRef = useRef<HTMLInputElement | null>(null);
145
149
  const colorNameToggleId = useId();
150
+ const colorPickerInputId = useId();
146
151
 
147
152
  const selectedColorPreview = useMemo(
148
153
  () => resolveProjectColorValue(value, projectColors) ?? value,
@@ -195,21 +200,10 @@ export function ColorModal({
195
200
  [savedColors],
196
201
  );
197
202
 
198
- const handleAddColorClick = () => {
199
- colorInputRef.current?.click();
200
- };
201
-
202
- const handlePreviewKeyDown = (
203
- event: React.KeyboardEvent<HTMLSpanElement>,
204
- ) => {
205
- if (event.key !== 'Enter' && event.key !== ' ') return;
206
- event.preventDefault();
207
- handleAddColorClick();
208
- };
209
-
210
- const handleColorPicked = (event: React.ChangeEvent<HTMLInputElement>) => {
211
- const picked = event.target.value;
203
+ const applyPickedColor = (raw: string | undefined) => {
204
+ const picked = raw?.trim().toLowerCase();
212
205
  if (!picked) return;
206
+
213
207
  setSavedColors((prev) => {
214
208
  if (prev.includes(picked)) return prev;
215
209
  const next = [...prev, picked];
@@ -218,7 +212,85 @@ export function ColorModal({
218
212
  });
219
213
  onSelect(picked);
220
214
  onClose();
221
- event.target.value = '';
215
+ };
216
+
217
+ const openPickerWithTempInput = () => {
218
+ if (typeof document === 'undefined') return;
219
+
220
+ const temp = document.createElement('input');
221
+ temp.type = 'color';
222
+ temp.value = selectedColorPreview?.toString() || '#000000';
223
+
224
+ // Keep it in the DOM and "not display:none" so Safari/iOS reliably opens it.
225
+ temp.style.position = 'fixed';
226
+ temp.style.left = '-1000px';
227
+ temp.style.top = '0';
228
+ temp.style.width = '40px';
229
+ temp.style.height = '40px';
230
+ temp.style.opacity = '0';
231
+
232
+ const cleanup = () => {
233
+ temp.removeEventListener('change', onChange);
234
+ temp.removeEventListener('input', onChange);
235
+ temp.removeEventListener('blur', cleanup);
236
+ temp.remove();
237
+ };
238
+
239
+ const onChange = () => {
240
+ applyPickedColor(temp.value);
241
+ cleanup();
242
+ };
243
+
244
+ temp.addEventListener('change', onChange);
245
+ temp.addEventListener('input', onChange);
246
+ temp.addEventListener('blur', cleanup);
247
+
248
+ document.body.appendChild(temp);
249
+ try {
250
+ temp.focus({ preventScroll: true });
251
+ } catch {
252
+ // no-op
253
+ }
254
+ // Next tick helps some browsers recognize it as a user-gesture flow.
255
+ requestAnimationFrame(() => temp.click());
256
+ };
257
+
258
+ const handleAddColorClick = () => {
259
+ const input = colorInputRef.current;
260
+
261
+ // Prefer showPicker when available (Chromium).
262
+ const maybeShowPicker = input
263
+ ? (
264
+ input as HTMLInputElement & {
265
+ showPicker?: () => void;
266
+ }
267
+ ).showPicker
268
+ : undefined;
269
+
270
+ if (input && typeof maybeShowPicker === 'function') {
271
+ try {
272
+ input.focus({ preventScroll: true });
273
+ } catch {
274
+ // no-op
275
+ }
276
+ maybeShowPicker.call(input);
277
+ return;
278
+ }
279
+
280
+ // Fallback: temporary input for Safari/iOS and browsers that block click on hidden inputs.
281
+ openPickerWithTempInput();
282
+ };
283
+
284
+ const handlePreviewKeyDown = (event: React.KeyboardEvent<HTMLElement>) => {
285
+ if (event.key !== 'Enter' && event.key !== ' ') return;
286
+ event.preventDefault();
287
+ handleAddColorClick();
288
+ };
289
+
290
+ const handleColorPicked = (event: React.ChangeEvent<HTMLInputElement>) => {
291
+ applyPickedColor(event.target.value);
292
+ // Keep the input value valid for type="color" (empty string can be invalid).
293
+ event.target.value = '#000000';
222
294
  };
223
295
 
224
296
  const handleSelectColor = (option: ColorOption) => {
@@ -245,16 +317,16 @@ export function ColorModal({
245
317
 
246
318
  <div className="color-modal__selected">
247
319
  <div className="color-modal__selected-info">
248
- <span
320
+ <label
321
+ htmlFor={colorPickerInputId}
249
322
  role="button"
250
323
  aria-label="Open color picker"
251
324
  tabIndex={0}
252
325
  title="Pick a color"
253
326
  className="color-modal__selected-preview"
254
327
  style={{ background: selectedColorPreview ?? 'transparent' }}
255
- onClick={handleAddColorClick}
256
328
  onKeyDown={handlePreviewKeyDown}
257
- />
329
+ ></label>
258
330
  <div>
259
331
  <p className="color-modal__selected-label">Selected color</p>
260
332
  <p className="color-modal__selected-value">{value ?? 'None'}</p>
@@ -316,13 +388,16 @@ export function ColorModal({
316
388
  options={savedColorOptions}
317
389
  emptyMessage="Add colors you use often for quick access."
318
390
  action={
319
- <button
320
- type="button"
321
- className="color-modal__link-button"
322
- onClick={handleAddColorClick}
323
- >
391
+ <span className="color-modal__link-button color-modal__add-color">
324
392
  Add color
325
- </button>
393
+ <input
394
+ type="color"
395
+ className="color-modal__add-color-input"
396
+ onChange={handleColorPicked}
397
+ defaultValue="#000000"
398
+ aria-label="Add color"
399
+ />
400
+ </span>
326
401
  }
327
402
  activeValue={value}
328
403
  onSelect={handleSelectColor}
@@ -330,9 +405,12 @@ export function ColorModal({
330
405
 
331
406
  <input
332
407
  ref={colorInputRef}
408
+ id={colorPickerInputId}
333
409
  type="color"
334
410
  className="color-modal__input"
335
411
  onChange={handleColorPicked}
412
+ defaultValue="#000000"
413
+ tabIndex={-1}
336
414
  />
337
415
  </Modal>
338
416
  );
@@ -1,5 +1,5 @@
1
1
  import React from 'react';
2
- import { JsonEditor } from 'json-edit-react';
2
+ import { JsonTextEditor } from '../components/JsonTextEditor';
3
3
  import { Localication } from '../types/PreviewConfig';
4
4
  import Modal from './Modal';
5
5
 
@@ -38,12 +38,11 @@ export function LocalicationModal({
38
38
  </div>
39
39
  <div className="localication-modal__body">
40
40
  <div className="localication-modal__editor">
41
- <JsonEditor
41
+ <JsonTextEditor
42
42
  rootName="localication"
43
- data={normalizedData}
44
- setData={(next) => onChange(next as Localication)}
43
+ value={normalizedData}
44
+ onChange={(next) => onChange(next as Localication)}
45
45
  className="localication-modal__json-editor"
46
- maxWidth={'100%'}
47
46
  />
48
47
  </div>
49
48
  </div>